diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..96f4ff30 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -10,12 +10,18 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // security + implementation("org.springframework.security:spring-security-crypto") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") annotationProcessor("jakarta.annotation:jakarta.annotation-api") + // test + testRuntimeOnly("com.h2database:h2") + // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad6..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} 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 00000000..afecc1e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,29 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class UserFacade { + + private final UserService userService; + + public UserInfo register(String loginId, String password, String name, LocalDate birthDate, String email) { + User user = userService.register(loginId, password, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getUserInfo(String loginId, String password) { + User user = userService.getUserInfo(loginId, password); + return UserInfo.from(user); + } + + public void updatePassword(String loginId, String currentPassword, String newPassword, LocalDate birthDate) { + userService.updatePassword(loginId, currentPassword, newPassword, birthDate); + } +} 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 00000000..2e5fff6c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +import java.time.LocalDate; + +public record UserInfo( + Long id, + String loginId, + String name, + LocalDate birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getId(), + user.getLoginId(), + user.getName(), + user.getBirthDate(), + user.getEmail() + ); + } + + public String getMaskedName() { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java new file mode 100644 index 00000000..f46cd678 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SecurityConfig.java @@ -0,0 +1,14 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -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 = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e566..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -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; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..12754ba7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,78 @@ +package com.loopers.domain.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "users", indexes = { + @Index(name = "idx_login_id", columnList = "login_id"), + @Index(name = "idx_email", columnList = "email") +}) +@Getter +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(name = "login_id", length = 50, nullable = false, unique = true) + private String loginId; + + @Column(name = "password", length = 255, nullable = false) + private String password; + + @Column(name = "name", length = 100, nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "email", length = 255, nullable = false) + private String email; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected User() {} + + public static User create(String loginId, String password, String name, LocalDate birthDate, String email) { + User user = new User(); + user.loginId = loginId; + user.password = password; + user.name = name; + user.birthDate = birthDate; + user.email = email; + return user; + } + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public void updatePassword(String newPassword) { + this.password = newPassword; + } +} 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 00000000..15889936 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} 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 00000000..cb2e78a8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,109 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder passwordEncoder; + + private static final Pattern PASSWORD_PATTERN = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]*$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final DateTimeFormatter YYYY_MM_DD_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter YY_MM_DD_FORMATTER = DateTimeFormatter.ofPattern("yyMMdd"); + + @Transactional + public User register(String loginId, String password, String name, LocalDate birthDate, String email) { + validateRegisterRequest(loginId, password, name, birthDate, email); + + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); + } + + String encryptedPassword = passwordEncoder.encode(password); + User user = User.create(loginId, encryptedPassword, name, birthDate, email); + + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUserInfo(String loginId, String password) { + return findUserAndValidatePassword(loginId, password); + } + + @Transactional + public void updatePassword(String loginId, String currentPassword, String newPassword, LocalDate birthDate) { + User user = findUserAndValidatePassword(loginId, currentPassword); + + if (passwordEncoder.matches(newPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."); + } + + validatePassword(newPassword, birthDate); + + String encryptedPassword = passwordEncoder.encode(newPassword); + user.updatePassword(encryptedPassword); + } + + private User findUserAndValidatePassword(String loginId, String password) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); + } + + return user; + } + + private void validateRegisterRequest(String loginId, String password, String name, LocalDate birthDate, String email) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + } + + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + } + + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + + validatePassword(password, birthDate); + validateEmail(email); + } + + private void validatePassword(String password, LocalDate birthDate) { + if (password == null || password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + + if (!PASSWORD_PATTERN.matcher(password).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 가능합니다."); + } + + String yyyyMMdd = birthDate.format(YYYY_MM_DD_FORMATTER); + String yyMMdd = birthDate.format(YY_MM_DD_FORMATTER); + + if (password.contains(yyyyMMdd) || password.contains(yyMMdd)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + private void validateEmail(String email) { + if (email == null || !EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "올바른 이메일 형식이 아닙니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} 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 00000000..fb0e51c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..aef1a68b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -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 = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 91737601..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} 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 00000000..ec1ecfbe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,70 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserV1Controller { + + private final UserFacade userFacade; + + @PostMapping("/register") + public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { + UserInfo userInfo = userFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + + UserV1Dto.RegisterResponse response = new UserV1Dto.RegisterResponse( + userInfo.id(), + userInfo.loginId(), + userInfo.name(), + userInfo.email() + ); + + return ApiResponse.success(response); + } + + @GetMapping("/info") + public ApiResponse getUserInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo userInfo = userFacade.getUserInfo(loginId, password); + + UserV1Dto.UserInfoResponse response = new UserV1Dto.UserInfoResponse( + userInfo.loginId(), + userInfo.getMaskedName(), + userInfo.birthDate(), + userInfo.email() + ); + + return ApiResponse.success(response); + } + + @PutMapping("/password") + public ApiResponse updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String currentPassword, + @RequestBody UserV1Dto.UpdatePasswordRequest request + ) { + UserInfo userInfo = userFacade.getUserInfo(loginId, currentPassword); + userFacade.updatePassword(loginId, currentPassword, request.newPassword(), userInfo.birthDate()); + + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..dae60a1b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.user; + +import java.time.LocalDate; + +public class UserV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) {} + + public record RegisterResponse( + Long id, + String loginId, + String name, + String email + ) {} + + public record UserInfoResponse( + String loginId, + String name, + LocalDate birthDate, + String email + ) {} + + public record UpdatePasswordRequest( + String newPassword + ) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -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 ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java new file mode 100644 index 00000000..3a00dc16 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,483 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService 단위 테스트") +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private BCryptPasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + @Nested + @DisplayName("회원가입") + class Register { + + @Test + @DisplayName("유효한 정보로 회원가입하면 성공한다") + void success() { + // given + String loginId = "testuser"; + String password = "Password1!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + when(userRepository.existsByLoginId(loginId)).thenReturn(false); + when(passwordEncoder.encode(password)).thenReturn("$2a$10$encryptedPassword"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + User result = userService.register(loginId, password, name, birthDate, email); + + // then + assertThat(result).isNotNull(); + verify(userRepository).existsByLoginId(loginId); + verify(passwordEncoder).encode(password); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("이미 존재하는 로그인 ID로 회원가입하면 CONFLICT 예외가 발생한다") + void throwsConflict_whenLoginIdExists() { + // given + String loginId = "existinguser"; + when(userRepository.existsByLoginId(loginId)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> userService.register( + loginId, + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.CONFLICT); + + verify(userRepository).existsByLoginId(loginId); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("로그인 ID가 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenLoginIdIsNull() { + // when & then + assertThatThrownBy(() -> userService.register( + null, + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + verify(userRepository, never()).existsByLoginId(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("로그인 ID가 빈 문자열이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenLoginIdIsEmpty() { + // when & then + assertThatThrownBy(() -> userService.register( + "", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("이름이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNameIsNull() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "Password1!", + null, + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("생년월일이 null이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenBirthDateIsNull() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "Password1!", + "홍길동", + null, + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("생년월일"); + } + + @Test + @DisplayName("비밀번호가 8자 미만이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenPasswordTooShort() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "Pass1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("8~16자"); + } + + @Test + @DisplayName("비밀번호가 16자 초과이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenPasswordTooLong() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "Password1!Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("8~16자"); + } + + @Test + @DisplayName("비밀번호에 공백이 포함되면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenPasswordContainsSpace() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "Pass word1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("영문 대소문자, 숫자, 특수문자"); + } + + @Test + @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenPasswordContainsBirthDateYYYYMMDD() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "19900115Pw!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("생년월일"); + } + + @Test + @DisplayName("비밀번호에 생년월일(yyMMdd)이 포함되면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenPasswordContainsBirthDateYYMMDD() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "900115Pass!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("생년월일"); + } + + @Test + @DisplayName("이메일 형식이 올바르지 않으면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenEmailFormatInvalid() { + // when & then + assertThatThrownBy(() -> userService.register( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "invalid-email" + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("이메일"); + } + } + + @Nested + @DisplayName("내 정보 조회") + class GetUserInfo { + + @Test + @DisplayName("유효한 로그인 ID와 비밀번호로 사용자를 조회하면 성공한다") + void success() { + // given + String loginId = "testuser"; + String rawPassword = "Password1!"; + String encodedPassword = "$2a$10$encryptedPassword"; + + User user = User.create(loginId, encodedPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); + + // when + User result = userService.getUserInfo(loginId, rawPassword); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(loginId); + verify(userRepository).findByLoginId(loginId); + verify(passwordEncoder).matches(rawPassword, encodedPassword); + } + + @Test + @DisplayName("존재하지 않는 로그인 ID로 조회하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenUserNotExists() { + // given + String loginId = "nonexistent"; + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.getUserInfo(loginId, "Password1!")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + + verify(userRepository).findByLoginId(loginId); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + } + + @Test + @DisplayName("비밀번호가 일치하지 않으면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenPasswordNotMatch() { + // given + String loginId = "testuser"; + String rawPassword = "WrongPassword1!"; + String encodedPassword = "$2a$10$encryptedPassword"; + + User user = User.create(loginId, encodedPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> userService.getUserInfo(loginId, rawPassword)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("비밀번호가 일치하지 않습니다"); + + verify(userRepository).findByLoginId(loginId); + verify(passwordEncoder).matches(rawPassword, encodedPassword); + } + } + + @Nested + @DisplayName("비밀번호 수정") + class UpdatePassword { + + @Test + @DisplayName("유효한 정보로 비밀번호를 수정하면 성공한다") + void success() { + // given + String loginId = "testuser"; + String currentPassword = "Password1!"; + String newPassword = "NewPassword2!"; + String encodedCurrentPassword = "$2a$10$encryptedPassword"; + String encodedNewPassword = "$2a$10$newEncryptedPassword"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + + User user = User.create(loginId, encodedCurrentPassword, "홍길동", birthDate, "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentPassword, encodedCurrentPassword)).thenReturn(true); + when(passwordEncoder.matches(newPassword, encodedCurrentPassword)).thenReturn(false); + when(passwordEncoder.encode(newPassword)).thenReturn(encodedNewPassword); + + // when + userService.updatePassword(loginId, currentPassword, newPassword, birthDate); + + // then + verify(userRepository).findByLoginId(loginId); + verify(passwordEncoder).matches(currentPassword, encodedCurrentPassword); + verify(passwordEncoder).matches(newPassword, encodedCurrentPassword); + verify(passwordEncoder).encode(newPassword); + } + + @Test + @DisplayName("존재하지 않는 사용자의 비밀번호를 수정하면 NOT_FOUND 예외가 발생한다") + void throwsNotFound_whenUserNotExists() { + // given + String loginId = "nonexistent"; + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userService.updatePassword( + loginId, + "Password1!", + "NewPassword2!", + LocalDate.of(1990, 1, 15) + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + + verify(userRepository).findByLoginId(loginId); + } + + @Test + @DisplayName("기존 비밀번호가 일치하지 않으면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenCurrentPasswordNotMatch() { + // given + String loginId = "testuser"; + String currentPassword = "WrongPassword1!"; + String encodedPassword = "$2a$10$encryptedPassword"; + + User user = User.create(loginId, encodedPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentPassword, encodedPassword)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> userService.updatePassword( + loginId, + currentPassword, + "NewPassword2!", + LocalDate.of(1990, 1, 15) + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("비밀번호가 일치하지 않습니다"); + + verify(userRepository).findByLoginId(loginId); + verify(passwordEncoder).matches(currentPassword, encodedPassword); + } + + @Test + @DisplayName("새 비밀번호가 기존 비밀번호와 동일하면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNewPasswordSameAsCurrent() { + // given + String loginId = "testuser"; + String currentPassword = "Password1!"; + String newPassword = "Password1!"; + String encodedPassword = "$2a$10$encryptedPassword"; + + User user = User.create(loginId, encodedPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentPassword, encodedPassword)).thenReturn(true); + when(passwordEncoder.matches(newPassword, encodedPassword)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> userService.updatePassword( + loginId, + currentPassword, + newPassword, + LocalDate.of(1990, 1, 15) + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다"); + + verify(userRepository).findByLoginId(loginId); + } + + @Test + @DisplayName("새 비밀번호가 8자 미만이면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNewPasswordTooShort() { + // given + String loginId = "testuser"; + String currentPassword = "Password1!"; + String newPassword = "Pass1!"; + String encodedPassword = "$2a$10$encryptedPassword"; + + User user = User.create(loginId, encodedPassword, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentPassword, encodedPassword)).thenReturn(true); + when(passwordEncoder.matches(newPassword, encodedPassword)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> userService.updatePassword( + loginId, + currentPassword, + newPassword, + LocalDate.of(1990, 1, 15) + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("8~16자"); + } + + @Test + @DisplayName("새 비밀번호에 생년월일이 포함되면 BAD_REQUEST 예외가 발생한다") + void throwsBadRequest_whenNewPasswordContainsBirthDate() { + // given + String loginId = "testuser"; + String currentPassword = "Password1!"; + String newPassword = "19900115Pw!"; + String encodedPassword = "$2a$10$encryptedPassword"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + + User user = User.create(loginId, encodedPassword, "홍길동", birthDate, "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentPassword, encodedPassword)).thenReturn(true); + when(passwordEncoder.matches(newPassword, encodedPassword)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> userService.updatePassword( + loginId, + currentPassword, + newPassword, + birthDate + )) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessageContaining("생년월일"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba6..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -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 ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // 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().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // 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.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // 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) - ); - } - } -} 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 00000000..832258ea --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,619 @@ +package com.loopers.interfaces.api; + +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String REGISTER_ENDPOINT = "/api/v1/users/register"; + private static final String USER_INFO_ENDPOINT = "/api/v1/users/info"; + private static final String UPDATE_PASSWORD_ENDPOINT = "/api/v1/users/password"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpEntity createRequestEntity(UserV1Dto.RegisterRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(request, headers); + } + + @DisplayName("POST /api/v1/users/register") + @Nested + class Register { + + @DisplayName("유효한 정보로 회원가입하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidInfoProvided() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("이미 존재하는 로그인 ID로 회원가입하면, 409 CONFLICT 응답을 반환한다.") + @Test + void returnsConflict_whenLoginIdAlreadyExists() { + // given + UserV1Dto.RegisterRequest firstRequest = new UserV1Dto.RegisterRequest( + "duplicateuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "first@example.com" + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(firstRequest), responseType); + + UserV1Dto.RegisterRequest duplicateRequest = new UserV1Dto.RegisterRequest( + "duplicateuser", + "Password2!", + "김철수", + LocalDate.of(1995, 5, 20), + "second@example.com" + ); + + // when + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(duplicateRequest), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("비밀번호가 8자 미만이면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenPasswordTooShort() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Pass1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호가 16자 초과이면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenPasswordTooLong() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호에 허용되지 않는 문자(공백)가 포함되면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenPasswordContainsInvalidCharacter() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Pass word1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenPasswordContainsBirthDateYYYYMMDD() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "19900115Pw!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일(yyMMdd)이 포함되면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenPasswordContainsBirthDateYYMMDD() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "900115Pass!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("이메일 형식이 올바르지 않으면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenEmailFormatInvalid() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "invalid-email" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("이름이 비어있으면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenNameIsEmpty() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("로그인 ID가 비어있으면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenLoginIdIsEmpty() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("생년월일이 null이면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenBirthDateIsNull() { + // given + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + null, + "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(request), responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/info") + @Nested + class GetUserInfo { + + @DisplayName("유효한 인증 정보로 내 정보를 조회하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidCredentialsProvided() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Password1!"); + HttpEntity requestEntity = new HttpEntity<>(headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(USER_INFO_ENDPOINT, HttpMethod.GET, requestEntity, responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo("testuser"), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(LocalDate.of(1990, 1, 15)), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("존재하지 않는 로그인 ID로 조회하면, 404 NOT_FOUND 응답을 반환한다.") + @Test + void returnsNotFound_whenUserNotExists() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "nonexistent"); + headers.set("X-Loopers-LoginPw", "Password1!"); + HttpEntity requestEntity = new HttpEntity<>(headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(USER_INFO_ENDPOINT, HttpMethod.GET, requestEntity, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("비밀번호가 일치하지 않으면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenPasswordNotMatch() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPassword1!"); + HttpEntity requestEntity = new HttpEntity<>(headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(USER_INFO_ENDPOINT, HttpMethod.GET, requestEntity, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("이름이 한 글자인 경우, 마스킹 처리하여 반환한다.") + @Test + void returnsMaskedName_whenNameIsSingleCharacter() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "김", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Password1!"); + HttpEntity requestEntity = new HttpEntity<>(headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(USER_INFO_ENDPOINT, HttpMethod.GET, requestEntity, responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("*") + ); + } + } + + @DisplayName("PUT /api/v1/users/password") + @Nested + class UpdatePassword { + + @DisplayName("유효한 정보로 비밀번호를 수정하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidInfoProvided() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + UserV1Dto.UpdatePasswordRequest updateRequest = new UserV1Dto.UpdatePasswordRequest("NewPassword2!"); + HttpEntity requestEntity = new HttpEntity<>(updateRequest, headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(UPDATE_PASSWORD_ENDPOINT, HttpMethod.PUT, requestEntity, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("기존 비밀번호가 일치하지 않으면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenCurrentPasswordNotMatch() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "WrongPassword1!"); + + UserV1Dto.UpdatePasswordRequest updateRequest = new UserV1Dto.UpdatePasswordRequest("NewPassword2!"); + HttpEntity requestEntity = new HttpEntity<>(updateRequest, headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(UPDATE_PASSWORD_ENDPOINT, HttpMethod.PUT, requestEntity, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 기존 비밀번호와 동일하면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordSameAsCurrent() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + UserV1Dto.UpdatePasswordRequest updateRequest = new UserV1Dto.UpdatePasswordRequest("Password1!"); + HttpEntity requestEntity = new HttpEntity<>(updateRequest, headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(UPDATE_PASSWORD_ENDPOINT, HttpMethod.PUT, requestEntity, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 8자 미만이면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordTooShort() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + UserV1Dto.UpdatePasswordRequest updateRequest = new UserV1Dto.UpdatePasswordRequest("Pass1!"); + HttpEntity requestEntity = new HttpEntity<>(updateRequest, headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(UPDATE_PASSWORD_ENDPOINT, HttpMethod.PUT, requestEntity, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면, 400 BAD_REQUEST 응답을 반환한다.") + @Test + void returnsBadRequest_whenNewPasswordContainsBirthDate() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", "testuser"); + headers.set("X-Loopers-LoginPw", "Password1!"); + + UserV1Dto.UpdatePasswordRequest updateRequest = new UserV1Dto.UpdatePasswordRequest("19900115Pw!"); + HttpEntity requestEntity = new HttpEntity<>(updateRequest, headers); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(UPDATE_PASSWORD_ENDPOINT, HttpMethod.PUT, requestEntity, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호 수정 후 새 비밀번호로 로그인할 수 있다.") + @Test + void canLoginWithNewPassword_afterPasswordUpdate() { + // given + UserV1Dto.RegisterRequest registerRequest = new UserV1Dto.RegisterRequest( + "testuser", + "Password1!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, createRequestEntity(registerRequest), registerResponseType); + + HttpHeaders updateHeaders = new HttpHeaders(); + updateHeaders.setContentType(MediaType.APPLICATION_JSON); + updateHeaders.set("X-Loopers-LoginId", "testuser"); + updateHeaders.set("X-Loopers-LoginPw", "Password1!"); + + UserV1Dto.UpdatePasswordRequest updateRequest = new UserV1Dto.UpdatePasswordRequest("NewPassword2!"); + HttpEntity updateRequestEntity = new HttpEntity<>(updateRequest, updateHeaders); + + ParameterizedTypeReference> updateResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(UPDATE_PASSWORD_ENDPOINT, HttpMethod.PUT, updateRequestEntity, updateResponseType); + + // when - 새 비밀번호로 로그인 + HttpHeaders loginHeaders = new HttpHeaders(); + loginHeaders.set("X-Loopers-LoginId", "testuser"); + loginHeaders.set("X-Loopers-LoginPw", "NewPassword2!"); + HttpEntity loginRequestEntity = new HttpEntity<>(loginHeaders); + + ParameterizedTypeReference> loginResponseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(USER_INFO_ENDPOINT, HttpMethod.GET, loginRequestEntity, loginResponseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } +} diff --git a/http/commerce-api/example-v1.http b/http/commerce-api/example-v1.http deleted file mode 100644 index 2a924d26..00000000 --- a/http/commerce-api/example-v1.http +++ /dev/null @@ -1,2 +0,0 @@ -### 예시 조회 -GET {{commerce-api}}/api/v1/examples/1 \ No newline at end of file