From a4c628859b6f4caf170ef36d3b2c450c1c7d33fe Mon Sep 17 00:00:00 2001 From: woo jin Date: Sun, 12 Jul 2026 22:06:50 +0900 Subject: [PATCH 01/31] feat: implement seller sign-up with layered architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POST /seller/signUp full HTTP integration specs (35 cases) - Implement controller → service → repository flow (TDD RED→GREEN) - Separate Request(web) and Command(application) DTOs - Split exception handlers per type via @ControllerAdvice (SRP) - Organize packages on a single layer axis(domain / application / infrastructure / api) --- build.gradle | 7 +- .../controller/SellerSignUpController.java | 18 + .../request/CreateSellerRequest.java | 15 + ...ataIntegrityViolationExceptionHandler.java | 15 + .../InvalidCommandExceptionHandler.java | 15 + .../application/InvalidCommandException.java | 4 + .../tdd/application/SellerSignUpService.java | 55 +++ .../command/CreateSellerCommand.java | 9 + .../java/com/study/tdd/domain/Seller.java | 35 ++ .../validation/UserPropertyValidator.java | 33 ++ .../config/PasswordEncoderConfiguration.java | 14 + .../config/SecurityConfiguration.java | 22 ++ .../persistence/SellerRepository.java | 14 + .../tdd/api/seller/signup/POST_specs.java | 368 ++++++++++++++++++ .../com/study/tdd/support/EmailGenerator.java | 10 + .../study/tdd/support/PasswordGenerator.java | 18 + .../com/study/tdd/support/TestDataSource.java | 26 ++ .../TestPasswordEncoderConfiguration.java | 24 ++ .../study/tdd/support/UsernameGenerator.java | 10 + 19 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/study/tdd/api/controller/SellerSignUpController.java create mode 100644 src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java create mode 100644 src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java create mode 100644 src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java create mode 100644 src/main/java/com/study/tdd/application/InvalidCommandException.java create mode 100644 src/main/java/com/study/tdd/application/SellerSignUpService.java create mode 100644 src/main/java/com/study/tdd/application/command/CreateSellerCommand.java create mode 100644 src/main/java/com/study/tdd/domain/Seller.java create mode 100644 src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java create mode 100644 src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java create mode 100644 src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java create mode 100644 src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java create mode 100644 src/test/java/com/study/tdd/api/seller/signup/POST_specs.java create mode 100644 src/test/java/com/study/tdd/support/EmailGenerator.java create mode 100644 src/test/java/com/study/tdd/support/PasswordGenerator.java create mode 100644 src/test/java/com/study/tdd/support/TestDataSource.java create mode 100644 src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java create mode 100644 src/test/java/com/study/tdd/support/UsernameGenerator.java diff --git a/build.gradle b/build.gradle index 329c5b5..d5e2a7e 100644 --- a/build.gradle +++ b/build.gradle @@ -18,10 +18,15 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-resttestclient' + testImplementation 'org.springframework.boot:spring-boot-restclient' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java new file mode 100644 index 0000000..8c55368 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java @@ -0,0 +1,18 @@ +package com.study.tdd.api.controller; + +import com.study.tdd.api.controller.request.CreateSellerRequest; +import com.study.tdd.application.SellerSignUpService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public record SellerSignUpController(SellerSignUpService sellerSignUpService) { + + @PostMapping("/seller/signUp") + ResponseEntity signUp(@RequestBody CreateSellerRequest request) { + sellerSignUpService.signUp(request.toCommand()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java b/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java new file mode 100644 index 0000000..30c0885 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java @@ -0,0 +1,15 @@ +package com.study.tdd.api.controller.request; + +import com.study.tdd.application.command.CreateSellerCommand; + +public record CreateSellerRequest( + String email, + String username, + String password, + String contactEmail +) { + + public CreateSellerCommand toCommand() { + return new CreateSellerCommand(email, username, password, contactEmail); + } +} diff --git a/src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java b/src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java new file mode 100644 index 0000000..ea4eecd --- /dev/null +++ b/src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java @@ -0,0 +1,15 @@ +package com.study.tdd.api.exception; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class DataIntegrityViolationExceptionHandler { + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handle() { + return ResponseEntity.badRequest().build(); + } +} diff --git a/src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java b/src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java new file mode 100644 index 0000000..3ef43a2 --- /dev/null +++ b/src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java @@ -0,0 +1,15 @@ +package com.study.tdd.api.exception; + +import com.study.tdd.application.InvalidCommandException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class InvalidCommandExceptionHandler { + + @ExceptionHandler(InvalidCommandException.class) + public ResponseEntity handle() { + return ResponseEntity.badRequest().build(); + } +} diff --git a/src/main/java/com/study/tdd/application/InvalidCommandException.java b/src/main/java/com/study/tdd/application/InvalidCommandException.java new file mode 100644 index 0000000..36ffef1 --- /dev/null +++ b/src/main/java/com/study/tdd/application/InvalidCommandException.java @@ -0,0 +1,4 @@ +package com.study.tdd.application; + +public class InvalidCommandException extends RuntimeException { +} diff --git a/src/main/java/com/study/tdd/application/SellerSignUpService.java b/src/main/java/com/study/tdd/application/SellerSignUpService.java new file mode 100644 index 0000000..613e3ab --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerSignUpService.java @@ -0,0 +1,55 @@ +package com.study.tdd.application; + +import java.util.UUID; + +import com.study.tdd.application.command.CreateSellerCommand; +import com.study.tdd.domain.Seller; +import com.study.tdd.infrastructure.persistence.SellerRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import static com.study.tdd.domain.validation.UserPropertyValidator.isEmailValid; +import static com.study.tdd.domain.validation.UserPropertyValidator.isPasswordValid; +import static com.study.tdd.domain.validation.UserPropertyValidator.isUsernameValid; + +@Service +public class SellerSignUpService { + + private final PasswordEncoder passwordEncoder; + private final SellerRepository repository; + + @Autowired + public SellerSignUpService( + PasswordEncoder passwordEncoder, + SellerRepository repository + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + } + + public void signUp(CreateSellerCommand command) { + if (!isCommandValid(command)) { + throw new InvalidCommandException(); + } + + repository.save(createSeller(command)); + } + + private Seller createSeller(CreateSellerCommand command) { + var seller = new Seller(); + seller.setId(UUID.randomUUID()); + seller.setEmail(command.email()); + seller.setUsername(command.username()); + seller.setHashedPassword(passwordEncoder.encode(command.password())); + seller.setContactEmail(command.contactEmail()); + return seller; + } + + private static boolean isCommandValid(CreateSellerCommand command) { + return isEmailValid(command.email()) + && isUsernameValid(command.username()) + && isPasswordValid(command.password()) + && isEmailValid(command.contactEmail()); + } +} diff --git a/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java b/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java new file mode 100644 index 0000000..1424574 --- /dev/null +++ b/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java @@ -0,0 +1,9 @@ +package com.study.tdd.application.command; + +public record CreateSellerCommand( + String email, + String username, + String password, + String contactEmail +) { +} diff --git a/src/main/java/com/study/tdd/domain/Seller.java b/src/main/java/com/study/tdd/domain/Seller.java new file mode 100644 index 0000000..b50a204 --- /dev/null +++ b/src/main/java/com/study/tdd/domain/Seller.java @@ -0,0 +1,35 @@ +package com.study.tdd.domain; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +public class Seller { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long dataKey; + + @Column(unique = true) + private UUID id; + + @Column(unique = true) + private String email; + + @Column(unique = true) + private String username; + + @Column(length = 1000) + private String hashedPassword; + + private String contactEmail; +} diff --git a/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java b/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java new file mode 100644 index 0000000..6258976 --- /dev/null +++ b/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java @@ -0,0 +1,33 @@ +package com.study.tdd.domain.validation; + +public class UserPropertyValidator { + + public static final String EMAIL_REGEX = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$"; + public static final String USERNAME_REGEX = "^[a-zA-Z0-9_-]{3,}$"; + + public static boolean isEmailValid(String email) { + return email != null && email.matches(EMAIL_REGEX); + } + + public static boolean isUsernameValid(String username) { + return username != null && username.matches(USERNAME_REGEX); + } + + public static boolean isPasswordValid(String password) { + return password != null + && password.length() >= 8 + && !contains4SequentialCharacters(password); + } + + private static boolean contains4SequentialCharacters(String s) { + for (int i = 0; i < s.length() - 3; i++) { + if (s.charAt(i) + 1 == s.charAt(i + 1) && + s.charAt(i + 1) + 1 == s.charAt(i + 2) && + s.charAt(i + 2) + 1 == s.charAt(i + 3)) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java new file mode 100644 index 0000000..1513e3a --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java @@ -0,0 +1,14 @@ +package com.study.tdd.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class PasswordEncoderConfiguration { + + @Bean + BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java new file mode 100644 index 0000000..1578df3 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -0,0 +1,22 @@ +package com.study.tdd.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfiguration { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + .requestMatchers("/seller/signUp").permitAll() + .anyRequest().authenticated() + ) + .build(); + } +} diff --git a/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java b/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java new file mode 100644 index 0000000..667e2f5 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java @@ -0,0 +1,14 @@ +package com.study.tdd.infrastructure.persistence; + +import java.util.Optional; +import java.util.UUID; + +import com.study.tdd.domain.Seller; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SellerRepository extends JpaRepository { + + Optional findById(UUID id); + + Optional findByEmail(String email); +} diff --git a/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java new file mode 100644 index 0000000..c550642 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java @@ -0,0 +1,368 @@ +package com.study.tdd.api.seller.signup; + +import com.study.tdd.TddApplication; +import com.study.tdd.application.command.CreateSellerCommand; +import com.study.tdd.domain.Seller; +import com.study.tdd.infrastructure.persistence.SellerRepository; +import com.study.tdd.support.TestPasswordEncoderConfiguration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static com.study.tdd.support.EmailGenerator.generateEmail; +import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.UsernameGenerator.generateUsername; + +@SpringBootTest( + classes = { + TddApplication.class, + TestPasswordEncoderConfiguration.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@AutoConfigureTestRestTemplate +@DisplayName("POST /seller/signUp") +public class POST_specs { + + @Test + void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + null, + generateUsername(), + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-email", + "invalid-email@", + "invalid-email@test", + "invalid-email@test.", + "invalid-email@.com" + }) + void email_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String email, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + email, + generateUsername(), + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + null, + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "se", + "seller ", + "seller.", + "seller!", + "seller@" + }) + void username_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "seller", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "0123456789", + "seller_", + "seller-" + }) + void username_속성이_올바른_형식을_따르면_204_No_Content_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + null, + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @MethodSource("com.study.tdd.support.TestDataSource#invalidPasswords") + void password_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String password, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + password, + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void email_속성에_이미_존재하는_이메일_주소가_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + "password", + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + "password", + generateEmail() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성에_이미_존재하는_사용자이름이_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String username = generateUsername(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 비밀번호를_올바르게_암호화한다( + @Autowired TestRestTemplate client, + @Autowired SellerRepository repository, + @Autowired PasswordEncoder encoder + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + generatePassword(), + generateEmail() + ); + + // Act + client.postForEntity("/seller/signUp", command, Void.class); + + // Assert + Seller seller = repository + .findAll() + .stream() + .filter(x -> x.getEmail().equals(command.email())) + .findFirst() + .orElseThrow(); + String actual = seller.getHashedPassword(); + assertThat(actual).isNotNull(); + assertThat(encoder.matches(command.password(), actual)).isTrue(); + } + + @ParameterizedTest + @MethodSource("com.study.tdd.support.TestDataSource#invalidEmails") + void contactEmail_속성이_올바르게_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + String contactEmail, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + generatePassword(), + contactEmail + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } +} diff --git a/src/test/java/com/study/tdd/support/EmailGenerator.java b/src/test/java/com/study/tdd/support/EmailGenerator.java new file mode 100644 index 0000000..7b999aa --- /dev/null +++ b/src/test/java/com/study/tdd/support/EmailGenerator.java @@ -0,0 +1,10 @@ +package com.study.tdd.support; + +import java.util.UUID; + +public class EmailGenerator { + + public static String generateEmail() { + return UUID.randomUUID() + "@test.com"; + } +} diff --git a/src/test/java/com/study/tdd/support/PasswordGenerator.java b/src/test/java/com/study/tdd/support/PasswordGenerator.java new file mode 100644 index 0000000..acdaf64 --- /dev/null +++ b/src/test/java/com/study/tdd/support/PasswordGenerator.java @@ -0,0 +1,18 @@ +package com.study.tdd.support; + +import java.security.SecureRandom; + +public class PasswordGenerator { + + private static final SecureRandom random = new SecureRandom(); + + public static String generatePassword() { + var mixture = new StringBuilder(); + for (int i = 0; i < 10; i++) { + mixture.append((char) random.nextInt('A', 'Z' + 1)); + mixture.append((char) random.nextInt('0', '9' + 1)); + mixture.append((char) random.nextInt('a', 'z' + 1)); + } + return "password" + mixture; + } +} diff --git a/src/test/java/com/study/tdd/support/TestDataSource.java b/src/test/java/com/study/tdd/support/TestDataSource.java new file mode 100644 index 0000000..fe785e1 --- /dev/null +++ b/src/test/java/com/study/tdd/support/TestDataSource.java @@ -0,0 +1,26 @@ +package com.study.tdd.support; + +public class TestDataSource { + + public static String[] invalidPasswords() { + return new String[] { + "", + "pass", + "pass123", + "1234password", + "password1234", + "pass5678word" + }; + } + + public static String[] invalidEmails() { + return new String[] { + null, + "invalid-email", + "invalid-email@", + "invalid-email@test", + "invalid-email@test.", + "invalid-email@.com" + }; + } +} diff --git a/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java b/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java new file mode 100644 index 0000000..69384e9 --- /dev/null +++ b/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java @@ -0,0 +1,24 @@ +package com.study.tdd.support; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; + +import static org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256; + +public class TestPasswordEncoderConfiguration { + + public static final int SALT_LENGTH = 16; + public static final int ITERATIONS = 10; + + @Bean + @Primary + Pbkdf2PasswordEncoder testPasswordEncoder() { + return new Pbkdf2PasswordEncoder( + "", + SALT_LENGTH, + ITERATIONS, + PBKDF2WithHmacSHA256 + ); + } +} diff --git a/src/test/java/com/study/tdd/support/UsernameGenerator.java b/src/test/java/com/study/tdd/support/UsernameGenerator.java new file mode 100644 index 0000000..31a52ce --- /dev/null +++ b/src/test/java/com/study/tdd/support/UsernameGenerator.java @@ -0,0 +1,10 @@ +package com.study.tdd.support; + +import java.util.UUID; + +public class UsernameGenerator { + + public static String generateUsername() { + return "username" + UUID.randomUUID(); + } +} From 0e48b136799cc772a4127b35ea7ad72a1927964a Mon Sep 17 00:00:00 2001 From: woo jin Date: Sun, 12 Jul 2026 22:29:57 +0900 Subject: [PATCH 02/31] docs: document key seller sign-up specs with KDoc --- .../tdd/api/seller/signup/POST_specs.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java index c550642..2883d67 100644 --- a/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java +++ b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java @@ -22,6 +22,21 @@ import static com.study.tdd.support.PasswordGenerator.generatePassword; import static com.study.tdd.support.UsernameGenerator.generateUsername; +/** + * {@code POST /seller/signUp} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

구현 세부사항이 아닌 입출력 계약(HTTP 요청 → 응답)만을 관찰하는 아웃사이드-인(outside-in) 방식으로 작성되었다. + * 컨트롤러/서비스/리포지토리를 직접 호출하지 않고, 실제 서버를 띄운 뒤 HTTP로 왕복하여 시스템 전체 동작을 확인한다.

+ * + *

테스트 구성의 핵심

+ *
    + *
  • {@code webEnvironment = RANDOM_PORT} — 실제 톰캣을 임의 포트로 기동하여 필터·시큐리티·직렬화까지 포함한 진짜 HTTP 경로를 검증한다.
  • + *
  • {@link TestPasswordEncoderConfiguration} — 운영용 BCrypt 대신 빠른 인코더를 {@code @Primary}로 주입해 테스트 속도를 확보한다(암호화 결과가 아니라 계약을 검증하므로 알고리즘 교체가 안전하다).
  • + *
  • {@code @AutoConfigureTestRestTemplate} — Spring Boot 4.1에서 분리된 {@link TestRestTemplate} 빈을 명시적으로 등록한다.
  • + *
+ * + *

모든 케이스는 Arrange-Act-Assert 3단계로 구조화되어 있으며, 메서드명이 곧 하나의 명세 문장(given-when-then)을 이룬다.

+ */ @SpringBootTest( classes = { TddApplication.class, @@ -33,6 +48,12 @@ @DisplayName("POST /seller/signUp") public class POST_specs { + /** + * 회원가입의 해피 패스(happy path) — 모든 속성이 유효하면 + * 본문 없이 성공을 뜻하는 {@code 204 No Content}를 반환해야 한다. + * + *

나머지 케이스들이 검증하는 "실패 계약"의 기준점이 되는 기본 명세이다.

+ */ @Test void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( @Autowired TestRestTemplate client @@ -79,6 +100,11 @@ public class POST_specs { assertThat(response.getStatusCode().value()).isEqualTo(400); } + /** + * 이메일 형식 검증의 대표 명세. 하나의 규칙(정규식)이 막아야 할 여러 경계값을 {@link ValueSource}로 나열해 한 메서드로 검증한다. + * + *

골뱅이 누락·도메인 누락·TLD 누락 등 "형식은 갖췄지만 무효"인 값들을 모아 검증 로직의 빈틈을 좁힌다. 케이스 추가는 문자열 한 줄로 끝난다.

+ */ @ParameterizedTest @ValueSource(strings = { "invalid-email", @@ -244,6 +270,13 @@ public class POST_specs { assertThat(response.getStatusCode().value()).isEqualTo(400); } + /** + * 이메일 유일성(uniqueness) 제약 명세. + * + *

Arrange 단계에서 먼저 한 번 가입시켜 상태를 만든 뒤, 같은 이메일로 재요청하면 실패해야 함을 검증한다. + * 형식 검증(단일 요청)과 달리 "선행 상태"에 의존하는 계약이라는 점이 핵심이다. + * 유일성은 DB의 {@code unique} 제약으로 보장되며, 그 위반이 HTTP {@code 400}으로 매핑되는지까지 확인한다.

+ */ @Test void email_속성에_이미_존재하는_이메일_주소가_지정되면_400_Bad_Request_상태코드를_반환한다( @Autowired TestRestTemplate client From becaab3461c98182dc3e8163730fed74dfb9d3bf Mon Sep 17 00:00:00 2001 From: woo jin Date: Sun, 12 Jul 2026 22:31:59 +0900 Subject: [PATCH 03/31] test: use BCrypt encoder in test config --- .../TestPasswordEncoderConfiguration.java | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java b/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java index 69384e9..c8a6f63 100644 --- a/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java +++ b/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java @@ -2,23 +2,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; -import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; - -import static org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class TestPasswordEncoderConfiguration { - public static final int SALT_LENGTH = 16; - public static final int ITERATIONS = 10; - @Bean @Primary - Pbkdf2PasswordEncoder testPasswordEncoder() { - return new Pbkdf2PasswordEncoder( - "", - SALT_LENGTH, - ITERATIONS, - PBKDF2WithHmacSHA256 - ); + BCryptPasswordEncoder testPasswordEncoder() { + return new BCryptPasswordEncoder(); } } From 2aa6f277f147049713a6a6e5768710637b026621 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Mon, 13 Jul 2026 12:05:56 +0900 Subject: [PATCH 04/31] docs: add seller access token issuance test scenarios --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 237aa0d..de83d85 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,12 @@ - password 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다. - email 속성에 이미 존재하는 이메일 주소가 지정되면 400 Bad Request 상태코드를 반환한다. - username 속성에 이미 존재하는 사용자이름이 지정되면 400 Bad Request 상태코드를 반환한다. -- 비밀번호를 올바르게 암호화한다. \ No newline at end of file +- 비밀번호를 올바르게 암호화한다. + +# 판매자 접근 토큰 발행 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 올바르게 요청하면 액세스 토큰을 반환한다. +- 액세스 토큰은 JWT 형식을 따른다. +- 존재하지 않는 이메일 주소가 사용되면 400 Bad Request 상태코드를 반환한다. +- 잘못된 비밀번호가 사용되면 400 Bad Request 상태코드를 반환한다. \ No newline at end of file From c9c9ddab08b73dbab33c7f9152bcbe51e7a6813e Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Mon, 13 Jul 2026 12:06:22 +0900 Subject: [PATCH 05/31] refactor: convert sign-up controller from record to class --- .../tdd/api/controller/SellerSignUpController.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java index 8c55368..3f26bfd 100644 --- a/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java +++ b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java @@ -2,13 +2,21 @@ import com.study.tdd.api.controller.request.CreateSellerRequest; import com.study.tdd.application.SellerSignUpService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController -public record SellerSignUpController(SellerSignUpService sellerSignUpService) { +public class SellerSignUpController { + + private final SellerSignUpService sellerSignUpService; + + @Autowired + public SellerSignUpController(SellerSignUpService sellerSignUpService) { + this.sellerSignUpService = sellerSignUpService; + } @PostMapping("/seller/signUp") ResponseEntity signUp(@RequestBody CreateSellerRequest request) { From 3211bb4d1a1f0b8202822a34fcc318ba03430f06 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Mon, 13 Jul 2026 12:06:43 +0900 Subject: [PATCH 06/31] feat: implement seller access token issuance API --- build.gradle | 3 + .../SellerIssueTokenController.java | 30 +++ .../request/IssueSellerTokenRequest.java | 10 + .../response/AccessTokenCarrier.java | 4 + .../application/SellerIssueTokenService.java | 39 ++++ .../application/query/IssueSellerToken.java | 4 + .../config/JwtConfiguration.java | 18 ++ .../infrastructure/config/JwtKeyHolder.java | 6 + .../config/SecurityConfiguration.java | 1 + .../tdd/infrastructure/jwt/JwtComposer.java | 28 +++ src/main/resources/application.yaml | 4 + .../tdd/api/seller/issueToken/POST_specs.java | 194 ++++++++++++++++++ .../com/study/tdd/support/JwtAssertions.java | 46 +++++ 13 files changed, 387 insertions(+) create mode 100644 src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java create mode 100644 src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/AccessTokenCarrier.java create mode 100644 src/main/java/com/study/tdd/application/SellerIssueTokenService.java create mode 100644 src/main/java/com/study/tdd/application/query/IssueSellerToken.java create mode 100644 src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java create mode 100644 src/main/java/com/study/tdd/infrastructure/config/JwtKeyHolder.java create mode 100644 src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java create mode 100644 src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java create mode 100644 src/test/java/com/study/tdd/support/JwtAssertions.java diff --git a/build.gradle b/build.gradle index d5e2a7e..0fce1a4 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' diff --git a/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java b/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java new file mode 100644 index 0000000..ab75e2c --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java @@ -0,0 +1,30 @@ +package com.study.tdd.api.controller; + +import com.study.tdd.api.controller.request.IssueSellerTokenRequest; +import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.application.SellerIssueTokenService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SellerIssueTokenController { + + private final SellerIssueTokenService sellerIssueTokenService; + + @Autowired + public SellerIssueTokenController(SellerIssueTokenService sellerIssueTokenService) { + this.sellerIssueTokenService = sellerIssueTokenService; + } + + @PostMapping("/seller/issueToken") + ResponseEntity issueToken(@RequestBody IssueSellerTokenRequest request) { + return sellerIssueTokenService + .issueToken(request.toQuery()) + .map(AccessTokenCarrier::new) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.badRequest().build()); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java b/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java new file mode 100644 index 0000000..167eb61 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java @@ -0,0 +1,10 @@ +package com.study.tdd.api.controller.request; + +import com.study.tdd.application.query.IssueSellerToken; + +public record IssueSellerTokenRequest(String email, String password) { + + public IssueSellerToken toQuery() { + return new IssueSellerToken(email, password); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/response/AccessTokenCarrier.java b/src/main/java/com/study/tdd/api/controller/response/AccessTokenCarrier.java new file mode 100644 index 0000000..eb3e928 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/AccessTokenCarrier.java @@ -0,0 +1,4 @@ +package com.study.tdd.api.controller.response; + +public record AccessTokenCarrier(String accessToken) { +} diff --git a/src/main/java/com/study/tdd/application/SellerIssueTokenService.java b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java new file mode 100644 index 0000000..9568355 --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java @@ -0,0 +1,39 @@ +package com.study.tdd.application; + +import java.util.Optional; + +import com.study.tdd.application.query.IssueSellerToken; +import com.study.tdd.infrastructure.jwt.JwtComposer; +import com.study.tdd.infrastructure.persistence.SellerRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class SellerIssueTokenService { + + private final PasswordEncoder passwordEncoder; + private final SellerRepository repository; + private final JwtComposer jwtComposer; + + @Autowired + public SellerIssueTokenService( + PasswordEncoder passwordEncoder, + SellerRepository repository, + JwtComposer jwtComposer + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + this.jwtComposer = jwtComposer; + } + + public Optional issueToken(IssueSellerToken query) { + return repository + .findByEmail(query.email()) + .filter(seller -> passwordEncoder.matches( + query.password(), + seller.getHashedPassword() + )) + .map(seller -> jwtComposer.composeToken(seller.getId(), "seller")); + } +} diff --git a/src/main/java/com/study/tdd/application/query/IssueSellerToken.java b/src/main/java/com/study/tdd/application/query/IssueSellerToken.java new file mode 100644 index 0000000..24c6671 --- /dev/null +++ b/src/main/java/com/study/tdd/application/query/IssueSellerToken.java @@ -0,0 +1,4 @@ +package com.study.tdd.application.query; + +public record IssueSellerToken(String email, String password) { +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java new file mode 100644 index 0000000..2f8fb73 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java @@ -0,0 +1,18 @@ +package com.study.tdd.infrastructure.config; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JwtConfiguration { + + @Bean + JwtKeyHolder jwtKeyHolder(@Value("${security.jwt.secret}") String secret) { + SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256"); + return new JwtKeyHolder(key); + } +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/JwtKeyHolder.java b/src/main/java/com/study/tdd/infrastructure/config/JwtKeyHolder.java new file mode 100644 index 0000000..2839c7f --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/config/JwtKeyHolder.java @@ -0,0 +1,6 @@ +package com.study.tdd.infrastructure.config; + +import javax.crypto.SecretKey; + +public record JwtKeyHolder(SecretKey key) { +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index 1578df3..907ce30 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -15,6 +15,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(requests -> requests .requestMatchers("/seller/signUp").permitAll() + .requestMatchers("/seller/issueToken").permitAll() .anyRequest().authenticated() ) .build(); diff --git a/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java b/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java new file mode 100644 index 0000000..70befcb --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java @@ -0,0 +1,28 @@ +package com.study.tdd.infrastructure.jwt; + +import java.util.UUID; + +import com.study.tdd.infrastructure.config.JwtKeyHolder; +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class JwtComposer { + + private final JwtKeyHolder jwtKeyHolder; + + @Autowired + public JwtComposer(JwtKeyHolder jwtKeyHolder) { + this.jwtKeyHolder = jwtKeyHolder; + } + + public String composeToken(UUID userId, String scope) { + return Jwts + .builder() + .subject(userId.toString()) + .claim("scp", scope) + .signWith(jwtKeyHolder.key()) + .compact(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2c4387d..2678b53 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,7 @@ spring: application: name: tdd + +security: + jwt: + secret: e4eaaaf2-d142-11e1-b3e4-080027620cdd diff --git a/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java b/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java new file mode 100644 index 0000000..c305762 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java @@ -0,0 +1,194 @@ +package com.study.tdd.api.seller.issueToken; + +import com.study.tdd.TddApplication; +import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.application.command.CreateSellerCommand; +import com.study.tdd.application.query.IssueSellerToken; +import com.study.tdd.support.TestPasswordEncoderConfiguration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.http.ResponseEntity; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static com.study.tdd.support.EmailGenerator.generateEmail; +import static com.study.tdd.support.JwtAssertions.conformsToJwtFormat; +import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.UsernameGenerator.generateUsername; + +/** + * {@code POST /seller/issueToken} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

판매자 자격증명(이메일 + 비밀번호)을 검증하고 접근 토큰(access token)을 발행하는 계약을 다룬다. + * 회원가입 명세와 마찬가지로 HTTP 입출력만 관찰하는 아웃사이드-인(outside-in) 방식이다.

+ * + *

테스트 시나리오

+ *
    + *
  • 올바르게 요청하면 200 OK 상태코드를 반환한다.
  • + *
  • 올바르게 요청하면 액세스 토큰을 반환한다.
  • + *
  • 액세스 토큰은 JWT 형식을 따른다.
  • + *
  • 존재하지 않는 이메일 주소가 사용되면 400 Bad Request 상태코드를 반환한다.
  • + *
  • 잘못된 비밀번호가 사용되면 400 Bad Request 상태코드를 반환한다.
  • + *
+ * + *

성공 케이스는 Arrange 단계에서 먼저 회원가입을 시켜 "존재하는 판매자"라는 선행 상태를 만든다. + * 실패 계약은 자격증명이 왜 틀렸는지(이메일 없음/비밀번호 불일치)를 구분하지 않고 + * 동일하게 {@code 400}을 반환한다 — 공격자에게 계정 존재 여부를 노출하지 않기 위함이다.

+ */ +@SpringBootTest( + classes = { + TddApplication.class, + TestPasswordEncoderConfiguration.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@AutoConfigureTestRestTemplate +@DisplayName("POST /seller/issueToken") +public class POST_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + password, + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/issueToken", + new IssueSellerToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 올바르게_요청하면_액세스_토큰을_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + password, + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/issueToken", + new IssueSellerToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().accessToken()).isNotNull(); + } + + @Test + void 액세스_토큰은_JWT_형식을_따른다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + password, + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/issueToken", + new IssueSellerToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + String actual = requireNonNull(response.getBody()).accessToken(); + assertThat(actual).satisfies(conformsToJwtFormat()); + } + + @Test + void 존재하지_않는_이메일_주소가_사용되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/issueToken", + new IssueSellerToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 잘못된_비밀번호가_사용되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + String wrongPassword = generatePassword(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + password, + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/issueToken", + new IssueSellerToken(email, wrongPassword), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } +} diff --git a/src/test/java/com/study/tdd/support/JwtAssertions.java b/src/test/java/com/study/tdd/support/JwtAssertions.java new file mode 100644 index 0000000..e003080 --- /dev/null +++ b/src/test/java/com/study/tdd/support/JwtAssertions.java @@ -0,0 +1,46 @@ +package com.study.tdd.support; + +import java.util.Base64; + +import tools.jackson.databind.ObjectMapper; +import org.assertj.core.api.ThrowingConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * JWT 형식 검증을 위한 커스텀 단언(assertion). + * + *

JWT(RFC 7519)는 {@code header.payload.signature} 세 부분이 점({@code .})으로 연결된 형식이다. + * header와 payload는 Base64URL로 인코딩된 JSON이어야 하고, signature는 Base64URL 문자열이어야 한다. + * 서명의 유효성까지는 검증하지 않는다 — 그것은 토큰을 소비하는 쪽 명세에서 다룬다.

+ */ +public class JwtAssertions { + + public static ThrowingConsumer conformsToJwtFormat() { + return s -> { + String[] parts = s.split("\\."); + assertThat(parts).hasSize(3); + assertThat(parts[0]).matches(JwtAssertions::isBase64UrlEncodedJson); + assertThat(parts[1]).matches(JwtAssertions::isBase64UrlEncodedJson); + assertThat(parts[2]).matches(JwtAssertions::isBase64UrlEncoded); + }; + } + + private static boolean isBase64UrlEncodedJson(String s) { + try { + new ObjectMapper().readTree(Base64.getUrlDecoder().decode(s)); + return true; + } catch (Exception exception) { + return false; + } + } + + private static boolean isBase64UrlEncoded(String s) { + try { + Base64.getUrlDecoder().decode(s); + return true; + } catch (Exception exception) { + return false; + } + } +} From b15b430b6564a54f0b4c5d0245f5375a5fe52091 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Mon, 13 Jul 2026 12:35:43 +0900 Subject: [PATCH 07/31] test: introduce @TddApiTest composed annotation for API spec bootstrap --- .../java/com/study/tdd/api/TddApiTest.java | 40 +++++++++++++++++++ .../tdd/api/seller/issueToken/POST_specs.java | 14 +------ .../tdd/api/seller/signup/POST_specs.java | 22 ++-------- 3 files changed, 46 insertions(+), 30 deletions(-) create mode 100644 src/test/java/com/study/tdd/api/TddApiTest.java diff --git a/src/test/java/com/study/tdd/api/TddApiTest.java b/src/test/java/com/study/tdd/api/TddApiTest.java new file mode 100644 index 0000000..7998722 --- /dev/null +++ b/src/test/java/com/study/tdd/api/TddApiTest.java @@ -0,0 +1,40 @@ +package com.study.tdd.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.study.tdd.TddApplication; +import com.study.tdd.support.TestPasswordEncoderConfiguration; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * API 명세 테스트 전용 합성 어노테이션(composed annotation). + * + *

모든 API 스펙 클래스가 반복하던 테스트 부트스트랩 구성을 한곳에 묶는다. + * 스펙 클래스는 {@code @TddApiTest} 하나만 붙이면 된다.

+ * + *

묶인 구성

+ *
    + *
  • {@code webEnvironment = RANDOM_PORT} — 실제 톰캣을 임의 포트로 기동하여 + * 필터·시큐리티·직렬화까지 포함한 진짜 HTTP 경로를 검증한다.
  • + *
  • {@link TestPasswordEncoderConfiguration} — 운영용 인코더 대신 빠른 인코더를 + * {@code @Primary}로 주입해 테스트 속도를 확보한다.
  • + *
  • {@code @AutoConfigureTestRestTemplate} — Spring Boot 4.1에서 분리된 + * {@code TestRestTemplate} 빈을 등록한다.
  • + *
+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest( + classes = { + TddApplication.class, + TestPasswordEncoderConfiguration.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@AutoConfigureTestRestTemplate +public @interface TddApiTest { +} diff --git a/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java b/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java index c305762..051b9c1 100644 --- a/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java +++ b/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java @@ -1,16 +1,13 @@ package com.study.tdd.api.seller.issueToken; -import com.study.tdd.TddApplication; +import com.study.tdd.api.TddApiTest; import com.study.tdd.api.controller.response.AccessTokenCarrier; import com.study.tdd.application.command.CreateSellerCommand; import com.study.tdd.application.query.IssueSellerToken; -import com.study.tdd.support.TestPasswordEncoderConfiguration; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.resttestclient.TestRestTemplate; -import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; import org.springframework.http.ResponseEntity; import static java.util.Objects.requireNonNull; @@ -39,14 +36,7 @@ * 실패 계약은 자격증명이 왜 틀렸는지(이메일 없음/비밀번호 불일치)를 구분하지 않고 * 동일하게 {@code 400}을 반환한다 — 공격자에게 계정 존재 여부를 노출하지 않기 위함이다.

*/ -@SpringBootTest( - classes = { - TddApplication.class, - TestPasswordEncoderConfiguration.class - }, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT -) -@AutoConfigureTestRestTemplate +@TddApiTest @DisplayName("POST /seller/issueToken") public class POST_specs { diff --git a/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java index 2883d67..bd6ae72 100644 --- a/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java +++ b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java @@ -1,19 +1,16 @@ package com.study.tdd.api.seller.signup; -import com.study.tdd.TddApplication; +import com.study.tdd.api.TddApiTest; import com.study.tdd.application.command.CreateSellerCommand; import com.study.tdd.domain.Seller; import com.study.tdd.infrastructure.persistence.SellerRepository; -import com.study.tdd.support.TestPasswordEncoderConfiguration; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.resttestclient.TestRestTemplate; -import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; @@ -28,23 +25,12 @@ *

구현 세부사항이 아닌 입출력 계약(HTTP 요청 → 응답)만을 관찰하는 아웃사이드-인(outside-in) 방식으로 작성되었다. * 컨트롤러/서비스/리포지토리를 직접 호출하지 않고, 실제 서버를 띄운 뒤 HTTP로 왕복하여 시스템 전체 동작을 확인한다.

* - *

테스트 구성의 핵심

- *
    - *
  • {@code webEnvironment = RANDOM_PORT} — 실제 톰캣을 임의 포트로 기동하여 필터·시큐리티·직렬화까지 포함한 진짜 HTTP 경로를 검증한다.
  • - *
  • {@link TestPasswordEncoderConfiguration} — 운영용 BCrypt 대신 빠른 인코더를 {@code @Primary}로 주입해 테스트 속도를 확보한다(암호화 결과가 아니라 계약을 검증하므로 알고리즘 교체가 안전하다).
  • - *
  • {@code @AutoConfigureTestRestTemplate} — Spring Boot 4.1에서 분리된 {@link TestRestTemplate} 빈을 명시적으로 등록한다.
  • - *
+ *

테스트 부트스트랩 구성(실서버 기동, 테스트용 인코더, {@link TestRestTemplate} 등록)은 + * {@link TddApiTest}에 합성되어 있다 — 구성의 상세와 의도는 그쪽 문서를 참고.

* *

모든 케이스는 Arrange-Act-Assert 3단계로 구조화되어 있으며, 메서드명이 곧 하나의 명세 문장(given-when-then)을 이룬다.

*/ -@SpringBootTest( - classes = { - TddApplication.class, - TestPasswordEncoderConfiguration.class - }, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT -) -@AutoConfigureTestRestTemplate +@TddApiTest @DisplayName("POST /seller/signUp") public class POST_specs { From 665ff25c1d6e504121f30d62e37b62340ec970ab Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Mon, 13 Jul 2026 13:47:35 +0900 Subject: [PATCH 08/31] docs: add shopper sign-up policy and test scenarios --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de83d85..0bca31f 100644 --- a/README.md +++ b/README.md @@ -23,4 +23,25 @@ - 올바르게 요청하면 액세스 토큰을 반환한다. - 액세스 토큰은 JWT 형식을 따른다. - 존재하지 않는 이메일 주소가 사용되면 400 Bad Request 상태코드를 반환한다. -- 잘못된 비밀번호가 사용되면 400 Bad Request 상태코드를 반환한다. \ No newline at end of file +- 잘못된 비밀번호가 사용되면 400 Bad Request 상태코드를 반환한다. + +# 구매자 회원 가입 정책 + +- 이메일 주소는 유일해야 한다. +- 사용자 이름은 유일해야 한다. +- 사용자 이름은 3자 이상의 영문자, 숫자, 하이픈, 밑줄 문자로 구성되어야 한다. +- 비밀번호는 8자 이상의 문자로 구성되어야 한다. + +# 구매자 회원 가입 API 테스트 시나리오 목록 + +- 올바르게 요청하면 204 No Content 상태코드를 반환한다. +- email 속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다. +- email 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다. +- username 속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다. +- username 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다. +- username 속성이 올바른 형식을 따르면 204 No Content 상태코드를 반환한다. +- password 속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다. +- password 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다. +- email 속성에 이미 존재하는 이메일 주소가 지정되면 400 Bad Request 상태코드를 반환한다. +- username 속성에 이미 존재하는 사용자 이름이 지정되면 400 Bad Request 상태코드를 반환한다. +- 비밀번호를 올바르게 암호화한다. \ No newline at end of file From 27f5879dd2ce72ae024711892c3e2812dcba2e21 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Mon, 13 Jul 2026 13:47:46 +0900 Subject: [PATCH 09/31] feat: implement shopper sign-up API --- .../controller/ShopperSignUpController.java | 26 ++ .../request/CreateShopperRequest.java | 14 + .../tdd/application/ShopperSignUpService.java | 53 +++ .../command/CreateShopperCommand.java | 8 + .../java/com/study/tdd/domain/Shopper.java | 33 ++ .../config/SecurityConfiguration.java | 1 + .../persistence/ShopperRepository.java | 14 + .../tdd/api/shopper/signup/POST_specs.java | 342 ++++++++++++++++++ 8 files changed, 491 insertions(+) create mode 100644 src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java create mode 100644 src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java create mode 100644 src/main/java/com/study/tdd/application/ShopperSignUpService.java create mode 100644 src/main/java/com/study/tdd/application/command/CreateShopperCommand.java create mode 100644 src/main/java/com/study/tdd/domain/Shopper.java create mode 100644 src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java create mode 100644 src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java diff --git a/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java b/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java new file mode 100644 index 0000000..de54955 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java @@ -0,0 +1,26 @@ +package com.study.tdd.api.controller; + +import com.study.tdd.api.controller.request.CreateShopperRequest; +import com.study.tdd.application.ShopperSignUpService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ShopperSignUpController { + + private final ShopperSignUpService shopperSignUpService; + + @Autowired + public ShopperSignUpController(ShopperSignUpService shopperSignUpService) { + this.shopperSignUpService = shopperSignUpService; + } + + @PostMapping("/shopper/signUp") + ResponseEntity signUp(@RequestBody CreateShopperRequest request) { + shopperSignUpService.signUp(request.toCommand()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java b/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java new file mode 100644 index 0000000..de9d06c --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java @@ -0,0 +1,14 @@ +package com.study.tdd.api.controller.request; + +import com.study.tdd.application.command.CreateShopperCommand; + +public record CreateShopperRequest( + String email, + String username, + String password +) { + + public CreateShopperCommand toCommand() { + return new CreateShopperCommand(email, username, password); + } +} diff --git a/src/main/java/com/study/tdd/application/ShopperSignUpService.java b/src/main/java/com/study/tdd/application/ShopperSignUpService.java new file mode 100644 index 0000000..e80cd9c --- /dev/null +++ b/src/main/java/com/study/tdd/application/ShopperSignUpService.java @@ -0,0 +1,53 @@ +package com.study.tdd.application; + +import java.util.UUID; + +import com.study.tdd.application.command.CreateShopperCommand; +import com.study.tdd.domain.Shopper; +import com.study.tdd.infrastructure.persistence.ShopperRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import static com.study.tdd.domain.validation.UserPropertyValidator.isEmailValid; +import static com.study.tdd.domain.validation.UserPropertyValidator.isPasswordValid; +import static com.study.tdd.domain.validation.UserPropertyValidator.isUsernameValid; + +@Service +public class ShopperSignUpService { + + private final PasswordEncoder passwordEncoder; + private final ShopperRepository repository; + + @Autowired + public ShopperSignUpService( + PasswordEncoder passwordEncoder, + ShopperRepository repository + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + } + + public void signUp(CreateShopperCommand command) { + if (!isCommandValid(command)) { + throw new InvalidCommandException(); + } + + repository.save(createShopper(command)); + } + + private Shopper createShopper(CreateShopperCommand command) { + var shopper = new Shopper(); + shopper.setId(UUID.randomUUID()); + shopper.setEmail(command.email()); + shopper.setUsername(command.username()); + shopper.setHashedPassword(passwordEncoder.encode(command.password())); + return shopper; + } + + private static boolean isCommandValid(CreateShopperCommand command) { + return isEmailValid(command.email()) + && isUsernameValid(command.username()) + && isPasswordValid(command.password()); + } +} diff --git a/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java b/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java new file mode 100644 index 0000000..68d8cb0 --- /dev/null +++ b/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java @@ -0,0 +1,8 @@ +package com.study.tdd.application.command; + +public record CreateShopperCommand( + String email, + String username, + String password +) { +} diff --git a/src/main/java/com/study/tdd/domain/Shopper.java b/src/main/java/com/study/tdd/domain/Shopper.java new file mode 100644 index 0000000..e939f2d --- /dev/null +++ b/src/main/java/com/study/tdd/domain/Shopper.java @@ -0,0 +1,33 @@ +package com.study.tdd.domain; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +public class Shopper { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long dataKey; + + @Column(unique = true) + private UUID id; + + @Column(unique = true) + private String email; + + @Column(unique = true) + private String username; + + @Column(length = 1000) + private String hashedPassword; +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index 907ce30..a6e2fef 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -16,6 +16,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(requests -> requests .requestMatchers("/seller/signUp").permitAll() .requestMatchers("/seller/issueToken").permitAll() + .requestMatchers("/shopper/signUp").permitAll() .anyRequest().authenticated() ) .build(); diff --git a/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java b/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java new file mode 100644 index 0000000..bfa3e68 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java @@ -0,0 +1,14 @@ +package com.study.tdd.infrastructure.persistence; + +import java.util.Optional; +import java.util.UUID; + +import com.study.tdd.domain.Shopper; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ShopperRepository extends JpaRepository { + + Optional findById(UUID id); + + Optional findByEmail(String email); +} diff --git a/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java b/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java new file mode 100644 index 0000000..7539de9 --- /dev/null +++ b/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java @@ -0,0 +1,342 @@ +package com.study.tdd.api.shopper.signup; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.application.command.CreateShopperCommand; +import com.study.tdd.domain.Shopper; +import com.study.tdd.infrastructure.persistence.ShopperRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static com.study.tdd.support.EmailGenerator.generateEmail; +import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.UsernameGenerator.generateUsername; + +/** + * {@code POST /shopper/signUp} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

구매자(Shopper) 회원가입 계약을 다룬다. 판매자 회원가입과 정책이 같지만 + * 연락 이메일(contactEmail) 속성이 없다는 점이 다르다. + * 입출력 계약(HTTP 요청 → 응답)만을 관찰하는 아웃사이드-인(outside-in) 방식으로 작성되었다.

+ * + *

테스트 부트스트랩 구성(실서버 기동, 테스트용 인코더, {@link TestRestTemplate} 등록)은 + * {@link TddApiTest}에 합성되어 있다.

+ * + *

모든 케이스는 Arrange-Act-Assert 3단계로 구조화되어 있으며, 메서드명이 곧 하나의 명세 문장(given-when-then)을 이룬다.

+ */ +@TddApiTest +@DisplayName("POST /shopper/signUp") +public class POST_specs { + + /** + * 회원가입의 해피 패스(happy path) — 모든 속성이 유효하면 + * 본문 없이 성공을 뜻하는 {@code 204 No Content}를 반환해야 한다. + */ + @Test + void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + null, + generateUsername(), + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-email", + "invalid-email@", + "invalid-email@test", + "invalid-email@test.", + "invalid-email@.com" + }) + void email_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String email, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + email, + generateUsername(), + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + null, + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "sh", + "shopper ", + "shopper.", + "shopper!", + "shopper@" + }) + void username_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "shopper", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "0123456789", + "shopper_", + "shopper-" + }) + void username_속성이_올바른_형식을_따르면_204_No_Content_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + null + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @MethodSource("com.study.tdd.support.TestDataSource#invalidPasswords") + void password_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String password, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + password + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + /** + * 이메일 유일성(uniqueness) 제약 명세. + * + *

Arrange 단계에서 먼저 한 번 가입시켜 상태를 만든 뒤, + * 같은 이메일로 재요청하면 실패해야 함을 검증한다.

+ */ + @Test + void email_속성에_이미_존재하는_이메일_주소가_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + generatePassword() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + generatePassword() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성에_이미_존재하는_사용자_이름이_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String username = generateUsername(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 비밀번호를_올바르게_암호화한다( + @Autowired TestRestTemplate client, + @Autowired ShopperRepository repository, + @Autowired PasswordEncoder encoder + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + generatePassword() + ); + + // Act + client.postForEntity("/shopper/signUp", command, Void.class); + + // Assert + Shopper shopper = repository + .findAll() + .stream() + .filter(x -> x.getEmail().equals(command.email())) + .findFirst() + .orElseThrow(); + String actual = shopper.getHashedPassword(); + assertThat(actual).isNotNull(); + assertThat(encoder.matches(command.password(), actual)).isTrue(); + } +} From 37f295231fa475b1f2f3349ab1a2dc3e91f7f6ad Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 11:30:29 +0900 Subject: [PATCH 10/31] refactor: centralize exception handling with BusinessException and ErrorCode --- ...ataIntegrityViolationExceptionHandler.java | 15 ----------- .../tdd/api/exception/ErrorResponse.java | 4 +++ .../api/exception/GlobalExceptionHandler.java | 19 ++++++++++++++ .../InvalidCommandExceptionHandler.java | 15 ----------- .../application/InvalidCommandException.java | 4 --- .../tdd/application/SellerSignUpService.java | 15 +++++++++-- .../tdd/application/ShopperSignUpService.java | 15 +++++++++-- .../exception/BusinessException.java | 15 +++++++++++ .../tdd/application/exception/ErrorCode.java | 8 ++++++ .../application/exception/UserErrorCode.java | 25 +++++++++++++++++++ 10 files changed, 97 insertions(+), 38 deletions(-) delete mode 100644 src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java create mode 100644 src/main/java/com/study/tdd/api/exception/ErrorResponse.java create mode 100644 src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java delete mode 100644 src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java delete mode 100644 src/main/java/com/study/tdd/application/InvalidCommandException.java create mode 100644 src/main/java/com/study/tdd/application/exception/BusinessException.java create mode 100644 src/main/java/com/study/tdd/application/exception/ErrorCode.java create mode 100644 src/main/java/com/study/tdd/application/exception/UserErrorCode.java diff --git a/src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java b/src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java deleted file mode 100644 index ea4eecd..0000000 --- a/src/main/java/com/study/tdd/api/exception/DataIntegrityViolationExceptionHandler.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.study.tdd.api.exception; - -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; - -@ControllerAdvice -public class DataIntegrityViolationExceptionHandler { - - @ExceptionHandler(DataIntegrityViolationException.class) - public ResponseEntity handle() { - return ResponseEntity.badRequest().build(); - } -} diff --git a/src/main/java/com/study/tdd/api/exception/ErrorResponse.java b/src/main/java/com/study/tdd/api/exception/ErrorResponse.java new file mode 100644 index 0000000..0493014 --- /dev/null +++ b/src/main/java/com/study/tdd/api/exception/ErrorResponse.java @@ -0,0 +1,4 @@ +package com.study.tdd.api.exception; + +public record ErrorResponse(String code, String message) { +} diff --git a/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java b/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7a7f011 --- /dev/null +++ b/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,19 @@ +package com.study.tdd.api.exception; + +import com.study.tdd.application.exception.BusinessException; +import com.study.tdd.application.exception.ErrorCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return ResponseEntity + .badRequest() + .body(new ErrorResponse(errorCode.code(), errorCode.message())); + } +} diff --git a/src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java b/src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java deleted file mode 100644 index 3ef43a2..0000000 --- a/src/main/java/com/study/tdd/api/exception/InvalidCommandExceptionHandler.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.study.tdd.api.exception; - -import com.study.tdd.application.InvalidCommandException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; - -@ControllerAdvice -public class InvalidCommandExceptionHandler { - - @ExceptionHandler(InvalidCommandException.class) - public ResponseEntity handle() { - return ResponseEntity.badRequest().build(); - } -} diff --git a/src/main/java/com/study/tdd/application/InvalidCommandException.java b/src/main/java/com/study/tdd/application/InvalidCommandException.java deleted file mode 100644 index 36ffef1..0000000 --- a/src/main/java/com/study/tdd/application/InvalidCommandException.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.study.tdd.application; - -public class InvalidCommandException extends RuntimeException { -} diff --git a/src/main/java/com/study/tdd/application/SellerSignUpService.java b/src/main/java/com/study/tdd/application/SellerSignUpService.java index 613e3ab..9230448 100644 --- a/src/main/java/com/study/tdd/application/SellerSignUpService.java +++ b/src/main/java/com/study/tdd/application/SellerSignUpService.java @@ -3,9 +3,12 @@ import java.util.UUID; import com.study.tdd.application.command.CreateSellerCommand; +import com.study.tdd.application.exception.BusinessException; +import com.study.tdd.application.exception.UserErrorCode; import com.study.tdd.domain.Seller; import com.study.tdd.infrastructure.persistence.SellerRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -30,10 +33,18 @@ public SellerSignUpService( public void signUp(CreateSellerCommand command) { if (!isCommandValid(command)) { - throw new InvalidCommandException(); + throw new BusinessException(UserErrorCode.INVALID_COMMAND); } - repository.save(createSeller(command)); + saveSeller(createSeller(command)); + } + + private void saveSeller(Seller seller) { + try { + repository.save(seller); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(UserErrorCode.DUPLICATE_USER_PROPERTY); + } } private Seller createSeller(CreateSellerCommand command) { diff --git a/src/main/java/com/study/tdd/application/ShopperSignUpService.java b/src/main/java/com/study/tdd/application/ShopperSignUpService.java index e80cd9c..8a13536 100644 --- a/src/main/java/com/study/tdd/application/ShopperSignUpService.java +++ b/src/main/java/com/study/tdd/application/ShopperSignUpService.java @@ -3,9 +3,12 @@ import java.util.UUID; import com.study.tdd.application.command.CreateShopperCommand; +import com.study.tdd.application.exception.BusinessException; +import com.study.tdd.application.exception.UserErrorCode; import com.study.tdd.domain.Shopper; import com.study.tdd.infrastructure.persistence.ShopperRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -30,10 +33,18 @@ public ShopperSignUpService( public void signUp(CreateShopperCommand command) { if (!isCommandValid(command)) { - throw new InvalidCommandException(); + throw new BusinessException(UserErrorCode.INVALID_COMMAND); } - repository.save(createShopper(command)); + saveShopper(createShopper(command)); + } + + private void saveShopper(Shopper shopper) { + try { + repository.save(shopper); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(UserErrorCode.DUPLICATE_USER_PROPERTY); + } } private Shopper createShopper(CreateShopperCommand command) { diff --git a/src/main/java/com/study/tdd/application/exception/BusinessException.java b/src/main/java/com/study/tdd/application/exception/BusinessException.java new file mode 100644 index 0000000..4dd00ca --- /dev/null +++ b/src/main/java/com/study/tdd/application/exception/BusinessException.java @@ -0,0 +1,15 @@ +package com.study.tdd.application.exception; + +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.message()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/study/tdd/application/exception/ErrorCode.java b/src/main/java/com/study/tdd/application/exception/ErrorCode.java new file mode 100644 index 0000000..3077d59 --- /dev/null +++ b/src/main/java/com/study/tdd/application/exception/ErrorCode.java @@ -0,0 +1,8 @@ +package com.study.tdd.application.exception; + +public interface ErrorCode { + + String code(); + + String message(); +} diff --git a/src/main/java/com/study/tdd/application/exception/UserErrorCode.java b/src/main/java/com/study/tdd/application/exception/UserErrorCode.java new file mode 100644 index 0000000..5792794 --- /dev/null +++ b/src/main/java/com/study/tdd/application/exception/UserErrorCode.java @@ -0,0 +1,25 @@ +package com.study.tdd.application.exception; + +public enum UserErrorCode implements ErrorCode { + + INVALID_COMMAND("USER_001", "사용자 속성이 올바르지 않습니다."), + DUPLICATE_USER_PROPERTY("USER_002", "이미 사용 중인 이메일 또는 사용자이름입니다."); + + private final String code; + private final String message; + + UserErrorCode(String code, String message) { + this.code = code; + this.message = message; + } + + @Override + public String code() { + return code; + } + + @Override + public String message() { + return message; + } +} From f42dc4ecbc223061b6970aec5499fdf8f2f49749 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 11:57:11 +0900 Subject: [PATCH 11/31] style: apply Naver Java convention formatting --- .../SellerIssueTokenController.java | 27 +++---- .../controller/SellerSignUpController.java | 21 ++--- .../controller/ShopperSignUpController.java | 21 ++--- .../request/CreateSellerRequest.java | 14 ++-- .../request/CreateShopperRequest.java | 12 +-- .../request/IssueSellerTokenRequest.java | 6 +- .../api/exception/GlobalExceptionHandler.java | 15 ++-- .../application/SellerIssueTokenService.java | 45 +++++------ .../tdd/application/SellerSignUpService.java | 77 ++++++++++--------- .../tdd/application/ShopperSignUpService.java | 73 +++++++++--------- .../command/CreateSellerCommand.java | 8 +- .../command/CreateShopperCommand.java | 6 +- .../exception/BusinessException.java | 16 ++-- .../tdd/application/exception/ErrorCode.java | 4 +- .../application/exception/UserErrorCode.java | 32 ++++---- .../java/com/study/tdd/domain/Seller.java | 24 +++--- .../java/com/study/tdd/domain/Shopper.java | 22 +++--- .../validation/UserPropertyValidator.java | 46 +++++------ .../config/JwtConfiguration.java | 10 +-- .../config/PasswordEncoderConfiguration.java | 8 +- .../config/SecurityConfiguration.java | 24 +++--- .../tdd/infrastructure/jwt/JwtComposer.java | 32 ++++---- .../persistence/SellerRepository.java | 5 +- .../persistence/ShopperRepository.java | 5 +- 24 files changed, 282 insertions(+), 271 deletions(-) diff --git a/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java b/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java index ab75e2c..2f70aea 100644 --- a/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java +++ b/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java @@ -3,6 +3,7 @@ import com.study.tdd.api.controller.request.IssueSellerTokenRequest; import com.study.tdd.api.controller.response.AccessTokenCarrier; import com.study.tdd.application.SellerIssueTokenService; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -12,19 +13,19 @@ @RestController public class SellerIssueTokenController { - private final SellerIssueTokenService sellerIssueTokenService; + private final SellerIssueTokenService sellerIssueTokenService; - @Autowired - public SellerIssueTokenController(SellerIssueTokenService sellerIssueTokenService) { - this.sellerIssueTokenService = sellerIssueTokenService; - } + @Autowired + public SellerIssueTokenController(SellerIssueTokenService sellerIssueTokenService) { + this.sellerIssueTokenService = sellerIssueTokenService; + } - @PostMapping("/seller/issueToken") - ResponseEntity issueToken(@RequestBody IssueSellerTokenRequest request) { - return sellerIssueTokenService - .issueToken(request.toQuery()) - .map(AccessTokenCarrier::new) - .map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.badRequest().build()); - } + @PostMapping("/seller/issueToken") + ResponseEntity issueToken(@RequestBody IssueSellerTokenRequest request) { + return sellerIssueTokenService + .issueToken(request.toQuery()) + .map(AccessTokenCarrier::new) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.badRequest().build()); + } } diff --git a/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java index 3f26bfd..15738e2 100644 --- a/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java +++ b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java @@ -2,6 +2,7 @@ import com.study.tdd.api.controller.request.CreateSellerRequest; import com.study.tdd.application.SellerSignUpService; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -11,16 +12,16 @@ @RestController public class SellerSignUpController { - private final SellerSignUpService sellerSignUpService; + private final SellerSignUpService sellerSignUpService; - @Autowired - public SellerSignUpController(SellerSignUpService sellerSignUpService) { - this.sellerSignUpService = sellerSignUpService; - } + @Autowired + public SellerSignUpController(SellerSignUpService sellerSignUpService) { + this.sellerSignUpService = sellerSignUpService; + } - @PostMapping("/seller/signUp") - ResponseEntity signUp(@RequestBody CreateSellerRequest request) { - sellerSignUpService.signUp(request.toCommand()); - return ResponseEntity.noContent().build(); - } + @PostMapping("/seller/signUp") + ResponseEntity signUp(@RequestBody CreateSellerRequest request) { + sellerSignUpService.signUp(request.toCommand()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java b/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java index de54955..2dc8b2e 100644 --- a/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java +++ b/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java @@ -2,6 +2,7 @@ import com.study.tdd.api.controller.request.CreateShopperRequest; import com.study.tdd.application.ShopperSignUpService; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -11,16 +12,16 @@ @RestController public class ShopperSignUpController { - private final ShopperSignUpService shopperSignUpService; + private final ShopperSignUpService shopperSignUpService; - @Autowired - public ShopperSignUpController(ShopperSignUpService shopperSignUpService) { - this.shopperSignUpService = shopperSignUpService; - } + @Autowired + public ShopperSignUpController(ShopperSignUpService shopperSignUpService) { + this.shopperSignUpService = shopperSignUpService; + } - @PostMapping("/shopper/signUp") - ResponseEntity signUp(@RequestBody CreateShopperRequest request) { - shopperSignUpService.signUp(request.toCommand()); - return ResponseEntity.noContent().build(); - } + @PostMapping("/shopper/signUp") + ResponseEntity signUp(@RequestBody CreateShopperRequest request) { + shopperSignUpService.signUp(request.toCommand()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java b/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java index 30c0885..ab04bd0 100644 --- a/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java +++ b/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java @@ -3,13 +3,13 @@ import com.study.tdd.application.command.CreateSellerCommand; public record CreateSellerRequest( - String email, - String username, - String password, - String contactEmail + String email, + String username, + String password, + String contactEmail ) { - public CreateSellerCommand toCommand() { - return new CreateSellerCommand(email, username, password, contactEmail); - } + public CreateSellerCommand toCommand() { + return new CreateSellerCommand(email, username, password, contactEmail); + } } diff --git a/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java b/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java index de9d06c..f1a6f4e 100644 --- a/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java +++ b/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java @@ -3,12 +3,12 @@ import com.study.tdd.application.command.CreateShopperCommand; public record CreateShopperRequest( - String email, - String username, - String password + String email, + String username, + String password ) { - public CreateShopperCommand toCommand() { - return new CreateShopperCommand(email, username, password); - } + public CreateShopperCommand toCommand() { + return new CreateShopperCommand(email, username, password); + } } diff --git a/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java b/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java index 167eb61..698b70b 100644 --- a/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java +++ b/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java @@ -4,7 +4,7 @@ public record IssueSellerTokenRequest(String email, String password) { - public IssueSellerToken toQuery() { - return new IssueSellerToken(email, password); - } + public IssueSellerToken toQuery() { + return new IssueSellerToken(email, password); + } } diff --git a/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java b/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java index 7a7f011..85eda79 100644 --- a/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.study.tdd.application.exception.BusinessException; import com.study.tdd.application.exception.ErrorCode; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -9,11 +10,11 @@ @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(BusinessException.class) - public ResponseEntity handleBusinessException(BusinessException exception) { - ErrorCode errorCode = exception.getErrorCode(); - return ResponseEntity - .badRequest() - .body(new ErrorResponse(errorCode.code(), errorCode.message())); - } + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return ResponseEntity + .badRequest() + .body(new ErrorResponse(errorCode.code(), errorCode.message())); + } } diff --git a/src/main/java/com/study/tdd/application/SellerIssueTokenService.java b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java index 9568355..625257a 100644 --- a/src/main/java/com/study/tdd/application/SellerIssueTokenService.java +++ b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java @@ -5,6 +5,7 @@ import com.study.tdd.application.query.IssueSellerToken; import com.study.tdd.infrastructure.jwt.JwtComposer; import com.study.tdd.infrastructure.persistence.SellerRepository; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -12,28 +13,28 @@ @Service public class SellerIssueTokenService { - private final PasswordEncoder passwordEncoder; - private final SellerRepository repository; - private final JwtComposer jwtComposer; + private final PasswordEncoder passwordEncoder; + private final SellerRepository repository; + private final JwtComposer jwtComposer; - @Autowired - public SellerIssueTokenService( - PasswordEncoder passwordEncoder, - SellerRepository repository, - JwtComposer jwtComposer - ) { - this.passwordEncoder = passwordEncoder; - this.repository = repository; - this.jwtComposer = jwtComposer; - } + @Autowired + public SellerIssueTokenService( + PasswordEncoder passwordEncoder, + SellerRepository repository, + JwtComposer jwtComposer + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + this.jwtComposer = jwtComposer; + } - public Optional issueToken(IssueSellerToken query) { - return repository - .findByEmail(query.email()) - .filter(seller -> passwordEncoder.matches( - query.password(), - seller.getHashedPassword() - )) - .map(seller -> jwtComposer.composeToken(seller.getId(), "seller")); - } + public Optional issueToken(IssueSellerToken query) { + return repository + .findByEmail(query.email()) + .filter(seller -> passwordEncoder.matches( + query.password(), + seller.getHashedPassword() + )) + .map(seller -> jwtComposer.composeToken(seller.getId(), "seller")); + } } diff --git a/src/main/java/com/study/tdd/application/SellerSignUpService.java b/src/main/java/com/study/tdd/application/SellerSignUpService.java index 9230448..6dd9d29 100644 --- a/src/main/java/com/study/tdd/application/SellerSignUpService.java +++ b/src/main/java/com/study/tdd/application/SellerSignUpService.java @@ -7,6 +7,7 @@ import com.study.tdd.application.exception.UserErrorCode; import com.study.tdd.domain.Seller; import com.study.tdd.infrastructure.persistence.SellerRepository; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; @@ -19,48 +20,48 @@ @Service public class SellerSignUpService { - private final PasswordEncoder passwordEncoder; - private final SellerRepository repository; + private final PasswordEncoder passwordEncoder; + private final SellerRepository repository; - @Autowired - public SellerSignUpService( - PasswordEncoder passwordEncoder, - SellerRepository repository - ) { - this.passwordEncoder = passwordEncoder; - this.repository = repository; - } + @Autowired + public SellerSignUpService( + PasswordEncoder passwordEncoder, + SellerRepository repository + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + } - public void signUp(CreateSellerCommand command) { - if (!isCommandValid(command)) { - throw new BusinessException(UserErrorCode.INVALID_COMMAND); - } + public void signUp(CreateSellerCommand command) { + if (!isCommandValid(command)) { + throw new BusinessException(UserErrorCode.INVALID_COMMAND); + } - saveSeller(createSeller(command)); - } + saveSeller(createSeller(command)); + } - private void saveSeller(Seller seller) { - try { - repository.save(seller); - } catch (DataIntegrityViolationException exception) { - throw new BusinessException(UserErrorCode.DUPLICATE_USER_PROPERTY); - } - } + private void saveSeller(Seller seller) { + try { + repository.save(seller); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(UserErrorCode.DUPLICATE_USER_PROPERTY); + } + } - private Seller createSeller(CreateSellerCommand command) { - var seller = new Seller(); - seller.setId(UUID.randomUUID()); - seller.setEmail(command.email()); - seller.setUsername(command.username()); - seller.setHashedPassword(passwordEncoder.encode(command.password())); - seller.setContactEmail(command.contactEmail()); - return seller; - } + private Seller createSeller(CreateSellerCommand command) { + var seller = new Seller(); + seller.setId(UUID.randomUUID()); + seller.setEmail(command.email()); + seller.setUsername(command.username()); + seller.setHashedPassword(passwordEncoder.encode(command.password())); + seller.setContactEmail(command.contactEmail()); + return seller; + } - private static boolean isCommandValid(CreateSellerCommand command) { - return isEmailValid(command.email()) - && isUsernameValid(command.username()) - && isPasswordValid(command.password()) - && isEmailValid(command.contactEmail()); - } + private static boolean isCommandValid(CreateSellerCommand command) { + return isEmailValid(command.email()) + && isUsernameValid(command.username()) + && isPasswordValid(command.password()) + && isEmailValid(command.contactEmail()); + } } diff --git a/src/main/java/com/study/tdd/application/ShopperSignUpService.java b/src/main/java/com/study/tdd/application/ShopperSignUpService.java index 8a13536..1a7cba2 100644 --- a/src/main/java/com/study/tdd/application/ShopperSignUpService.java +++ b/src/main/java/com/study/tdd/application/ShopperSignUpService.java @@ -7,6 +7,7 @@ import com.study.tdd.application.exception.UserErrorCode; import com.study.tdd.domain.Shopper; import com.study.tdd.infrastructure.persistence.ShopperRepository; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; @@ -19,46 +20,46 @@ @Service public class ShopperSignUpService { - private final PasswordEncoder passwordEncoder; - private final ShopperRepository repository; + private final PasswordEncoder passwordEncoder; + private final ShopperRepository repository; - @Autowired - public ShopperSignUpService( - PasswordEncoder passwordEncoder, - ShopperRepository repository - ) { - this.passwordEncoder = passwordEncoder; - this.repository = repository; - } + @Autowired + public ShopperSignUpService( + PasswordEncoder passwordEncoder, + ShopperRepository repository + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + } - public void signUp(CreateShopperCommand command) { - if (!isCommandValid(command)) { - throw new BusinessException(UserErrorCode.INVALID_COMMAND); - } + public void signUp(CreateShopperCommand command) { + if (!isCommandValid(command)) { + throw new BusinessException(UserErrorCode.INVALID_COMMAND); + } - saveShopper(createShopper(command)); - } + saveShopper(createShopper(command)); + } - private void saveShopper(Shopper shopper) { - try { - repository.save(shopper); - } catch (DataIntegrityViolationException exception) { - throw new BusinessException(UserErrorCode.DUPLICATE_USER_PROPERTY); - } - } + private void saveShopper(Shopper shopper) { + try { + repository.save(shopper); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(UserErrorCode.DUPLICATE_USER_PROPERTY); + } + } - private Shopper createShopper(CreateShopperCommand command) { - var shopper = new Shopper(); - shopper.setId(UUID.randomUUID()); - shopper.setEmail(command.email()); - shopper.setUsername(command.username()); - shopper.setHashedPassword(passwordEncoder.encode(command.password())); - return shopper; - } + private Shopper createShopper(CreateShopperCommand command) { + var shopper = new Shopper(); + shopper.setId(UUID.randomUUID()); + shopper.setEmail(command.email()); + shopper.setUsername(command.username()); + shopper.setHashedPassword(passwordEncoder.encode(command.password())); + return shopper; + } - private static boolean isCommandValid(CreateShopperCommand command) { - return isEmailValid(command.email()) - && isUsernameValid(command.username()) - && isPasswordValid(command.password()); - } + private static boolean isCommandValid(CreateShopperCommand command) { + return isEmailValid(command.email()) + && isUsernameValid(command.username()) + && isPasswordValid(command.password()); + } } diff --git a/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java b/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java index 1424574..3e09175 100644 --- a/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java +++ b/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java @@ -1,9 +1,9 @@ package com.study.tdd.application.command; public record CreateSellerCommand( - String email, - String username, - String password, - String contactEmail + String email, + String username, + String password, + String contactEmail ) { } diff --git a/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java b/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java index 68d8cb0..b5211d8 100644 --- a/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java +++ b/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java @@ -1,8 +1,8 @@ package com.study.tdd.application.command; public record CreateShopperCommand( - String email, - String username, - String password + String email, + String username, + String password ) { } diff --git a/src/main/java/com/study/tdd/application/exception/BusinessException.java b/src/main/java/com/study/tdd/application/exception/BusinessException.java index 4dd00ca..c50caeb 100644 --- a/src/main/java/com/study/tdd/application/exception/BusinessException.java +++ b/src/main/java/com/study/tdd/application/exception/BusinessException.java @@ -2,14 +2,14 @@ public class BusinessException extends RuntimeException { - private final ErrorCode errorCode; + private final ErrorCode errorCode; - public BusinessException(ErrorCode errorCode) { - super(errorCode.message()); - this.errorCode = errorCode; - } + public BusinessException(ErrorCode errorCode) { + super(errorCode.message()); + this.errorCode = errorCode; + } - public ErrorCode getErrorCode() { - return errorCode; - } + public ErrorCode getErrorCode() { + return errorCode; + } } diff --git a/src/main/java/com/study/tdd/application/exception/ErrorCode.java b/src/main/java/com/study/tdd/application/exception/ErrorCode.java index 3077d59..3f3ff4c 100644 --- a/src/main/java/com/study/tdd/application/exception/ErrorCode.java +++ b/src/main/java/com/study/tdd/application/exception/ErrorCode.java @@ -2,7 +2,7 @@ public interface ErrorCode { - String code(); + String code(); - String message(); + String message(); } diff --git a/src/main/java/com/study/tdd/application/exception/UserErrorCode.java b/src/main/java/com/study/tdd/application/exception/UserErrorCode.java index 5792794..4894820 100644 --- a/src/main/java/com/study/tdd/application/exception/UserErrorCode.java +++ b/src/main/java/com/study/tdd/application/exception/UserErrorCode.java @@ -2,24 +2,24 @@ public enum UserErrorCode implements ErrorCode { - INVALID_COMMAND("USER_001", "사용자 속성이 올바르지 않습니다."), - DUPLICATE_USER_PROPERTY("USER_002", "이미 사용 중인 이메일 또는 사용자이름입니다."); + INVALID_COMMAND("USER_001", "사용자 속성이 올바르지 않습니다."), + DUPLICATE_USER_PROPERTY("USER_002", "이미 사용 중인 이메일 또는 사용자이름입니다."); - private final String code; - private final String message; + private final String code; + private final String message; - UserErrorCode(String code, String message) { - this.code = code; - this.message = message; - } + UserErrorCode(String code, String message) { + this.code = code; + this.message = message; + } - @Override - public String code() { - return code; - } + @Override + public String code() { + return code; + } - @Override - public String message() { - return message; - } + @Override + public String message() { + return message; + } } diff --git a/src/main/java/com/study/tdd/domain/Seller.java b/src/main/java/com/study/tdd/domain/Seller.java index b50a204..6291322 100644 --- a/src/main/java/com/study/tdd/domain/Seller.java +++ b/src/main/java/com/study/tdd/domain/Seller.java @@ -15,21 +15,21 @@ @Setter public class Seller { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long dataKey; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long dataKey; - @Column(unique = true) - private UUID id; + @Column(unique = true) + private UUID id; - @Column(unique = true) - private String email; + @Column(unique = true) + private String email; - @Column(unique = true) - private String username; + @Column(unique = true) + private String username; - @Column(length = 1000) - private String hashedPassword; + @Column(length = 1000) + private String hashedPassword; - private String contactEmail; + private String contactEmail; } diff --git a/src/main/java/com/study/tdd/domain/Shopper.java b/src/main/java/com/study/tdd/domain/Shopper.java index e939f2d..5acd5ab 100644 --- a/src/main/java/com/study/tdd/domain/Shopper.java +++ b/src/main/java/com/study/tdd/domain/Shopper.java @@ -15,19 +15,19 @@ @Setter public class Shopper { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long dataKey; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long dataKey; - @Column(unique = true) - private UUID id; + @Column(unique = true) + private UUID id; - @Column(unique = true) - private String email; + @Column(unique = true) + private String email; - @Column(unique = true) - private String username; + @Column(unique = true) + private String username; - @Column(length = 1000) - private String hashedPassword; + @Column(length = 1000) + private String hashedPassword; } diff --git a/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java b/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java index 6258976..2d82b1f 100644 --- a/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java +++ b/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java @@ -2,32 +2,32 @@ public class UserPropertyValidator { - public static final String EMAIL_REGEX = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$"; - public static final String USERNAME_REGEX = "^[a-zA-Z0-9_-]{3,}$"; + public static final String EMAIL_REGEX = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$"; + public static final String USERNAME_REGEX = "^[a-zA-Z0-9_-]{3,}$"; - public static boolean isEmailValid(String email) { - return email != null && email.matches(EMAIL_REGEX); - } + public static boolean isEmailValid(String email) { + return email != null && email.matches(EMAIL_REGEX); + } - public static boolean isUsernameValid(String username) { - return username != null && username.matches(USERNAME_REGEX); - } + public static boolean isUsernameValid(String username) { + return username != null && username.matches(USERNAME_REGEX); + } - public static boolean isPasswordValid(String password) { - return password != null - && password.length() >= 8 - && !contains4SequentialCharacters(password); - } + public static boolean isPasswordValid(String password) { + return password != null + && password.length() >= 8 + && !contains4SequentialCharacters(password); + } - private static boolean contains4SequentialCharacters(String s) { - for (int i = 0; i < s.length() - 3; i++) { - if (s.charAt(i) + 1 == s.charAt(i + 1) && - s.charAt(i + 1) + 1 == s.charAt(i + 2) && - s.charAt(i + 2) + 1 == s.charAt(i + 3)) { - return true; - } - } + private static boolean contains4SequentialCharacters(String s) { + for (int i = 0; i < s.length() - 3; i++) { + if (s.charAt(i) + 1 == s.charAt(i + 1) && + s.charAt(i + 1) + 1 == s.charAt(i + 2) && + s.charAt(i + 2) + 1 == s.charAt(i + 3)) { + return true; + } + } - return false; - } + return false; + } } diff --git a/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java index 2f8fb73..2a9a0eb 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java @@ -10,9 +10,9 @@ @Configuration public class JwtConfiguration { - @Bean - JwtKeyHolder jwtKeyHolder(@Value("${security.jwt.secret}") String secret) { - SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256"); - return new JwtKeyHolder(key); - } + @Bean + JwtKeyHolder jwtKeyHolder(@Value("${security.jwt.secret}") String secret) { + SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256"); + return new JwtKeyHolder(key); + } } diff --git a/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java index 1513e3a..09e3e33 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java @@ -7,8 +7,8 @@ @Configuration public class PasswordEncoderConfiguration { - @Bean - BCryptPasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + @Bean + BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index a6e2fef..54faea6 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -9,16 +9,16 @@ @Configuration public class SecurityConfiguration { - @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(requests -> requests - .requestMatchers("/seller/signUp").permitAll() - .requestMatchers("/seller/issueToken").permitAll() - .requestMatchers("/shopper/signUp").permitAll() - .anyRequest().authenticated() - ) - .build(); - } + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + .requestMatchers("/seller/signUp").permitAll() + .requestMatchers("/seller/issueToken").permitAll() + .requestMatchers("/shopper/signUp").permitAll() + .anyRequest().authenticated() + ) + .build(); + } } diff --git a/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java b/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java index 70befcb..bc88265 100644 --- a/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java +++ b/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java @@ -3,26 +3,28 @@ import java.util.UUID; import com.study.tdd.infrastructure.config.JwtKeyHolder; + import io.jsonwebtoken.Jwts; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class JwtComposer { - private final JwtKeyHolder jwtKeyHolder; - - @Autowired - public JwtComposer(JwtKeyHolder jwtKeyHolder) { - this.jwtKeyHolder = jwtKeyHolder; - } - - public String composeToken(UUID userId, String scope) { - return Jwts - .builder() - .subject(userId.toString()) - .claim("scp", scope) - .signWith(jwtKeyHolder.key()) - .compact(); - } + private final JwtKeyHolder jwtKeyHolder; + + @Autowired + public JwtComposer(JwtKeyHolder jwtKeyHolder) { + this.jwtKeyHolder = jwtKeyHolder; + } + + public String composeToken(UUID userId, String scope) { + return Jwts + .builder() + .subject(userId.toString()) + .claim("scp", scope) + .signWith(jwtKeyHolder.key()) + .compact(); + } } diff --git a/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java b/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java index 667e2f5..c638aa9 100644 --- a/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java +++ b/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java @@ -4,11 +4,12 @@ import java.util.UUID; import com.study.tdd.domain.Seller; + import org.springframework.data.jpa.repository.JpaRepository; public interface SellerRepository extends JpaRepository { - Optional findById(UUID id); + Optional findById(UUID id); - Optional findByEmail(String email); + Optional findByEmail(String email); } diff --git a/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java b/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java index bfa3e68..28ec618 100644 --- a/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java +++ b/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java @@ -4,11 +4,12 @@ import java.util.UUID; import com.study.tdd.domain.Shopper; + import org.springframework.data.jpa.repository.JpaRepository; public interface ShopperRepository extends JpaRepository { - Optional findById(UUID id); + Optional findById(UUID id); - Optional findByEmail(String email); + Optional findByEmail(String email); } From 9adf899f1d0a30c9dd7059ac15bf9f94145790b2 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 12:12:45 +0900 Subject: [PATCH 12/31] refactor: abstract security components behind application-layer ports --- .../tdd/application/SellerIssueTokenService.java | 11 ++++++----- .../tdd/application/security/TokenIssuer.java | 8 ++++++++ .../tdd/application/security/TokenScope.java | 16 ++++++++++++++++ .../infrastructure/config/JwtConfiguration.java | 4 +++- .../config/PasswordEncoderConfiguration.java | 3 ++- .../{JwtComposer.java => JwtTokenIssuer.java} | 13 ++++++++----- 6 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/study/tdd/application/security/TokenIssuer.java create mode 100644 src/main/java/com/study/tdd/application/security/TokenScope.java rename src/main/java/com/study/tdd/infrastructure/jwt/{JwtComposer.java => JwtTokenIssuer.java} (56%) diff --git a/src/main/java/com/study/tdd/application/SellerIssueTokenService.java b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java index 625257a..c295956 100644 --- a/src/main/java/com/study/tdd/application/SellerIssueTokenService.java +++ b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java @@ -3,7 +3,8 @@ import java.util.Optional; import com.study.tdd.application.query.IssueSellerToken; -import com.study.tdd.infrastructure.jwt.JwtComposer; +import com.study.tdd.application.security.TokenIssuer; +import com.study.tdd.application.security.TokenScope; import com.study.tdd.infrastructure.persistence.SellerRepository; import org.springframework.beans.factory.annotation.Autowired; @@ -15,17 +16,17 @@ public class SellerIssueTokenService { private final PasswordEncoder passwordEncoder; private final SellerRepository repository; - private final JwtComposer jwtComposer; + private final TokenIssuer tokenIssuer; @Autowired public SellerIssueTokenService( PasswordEncoder passwordEncoder, SellerRepository repository, - JwtComposer jwtComposer + TokenIssuer tokenIssuer ) { this.passwordEncoder = passwordEncoder; this.repository = repository; - this.jwtComposer = jwtComposer; + this.tokenIssuer = tokenIssuer; } public Optional issueToken(IssueSellerToken query) { @@ -35,6 +36,6 @@ public Optional issueToken(IssueSellerToken query) { query.password(), seller.getHashedPassword() )) - .map(seller -> jwtComposer.composeToken(seller.getId(), "seller")); + .map(seller -> tokenIssuer.issueToken(seller.getId(), TokenScope.SELLER)); } } diff --git a/src/main/java/com/study/tdd/application/security/TokenIssuer.java b/src/main/java/com/study/tdd/application/security/TokenIssuer.java new file mode 100644 index 0000000..74d3af9 --- /dev/null +++ b/src/main/java/com/study/tdd/application/security/TokenIssuer.java @@ -0,0 +1,8 @@ +package com.study.tdd.application.security; + +import java.util.UUID; + +public interface TokenIssuer { + + String issueToken(UUID subjectId, TokenScope scope); +} diff --git a/src/main/java/com/study/tdd/application/security/TokenScope.java b/src/main/java/com/study/tdd/application/security/TokenScope.java new file mode 100644 index 0000000..cf0a8c1 --- /dev/null +++ b/src/main/java/com/study/tdd/application/security/TokenScope.java @@ -0,0 +1,16 @@ +package com.study.tdd.application.security; + +public enum TokenScope { + + SELLER("seller"); + + private final String value; + + TokenScope(String value) { + this.value = value; + } + + public String value() { + return value; + } +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java index 2a9a0eb..dab47fe 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java @@ -1,5 +1,7 @@ package com.study.tdd.infrastructure.config; +import java.nio.charset.StandardCharsets; + import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; @@ -12,7 +14,7 @@ public class JwtConfiguration { @Bean JwtKeyHolder jwtKeyHolder(@Value("${security.jwt.secret}") String secret) { - SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256"); + SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); return new JwtKeyHolder(key); } } diff --git a/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java index 09e3e33..bdb186d 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java @@ -3,12 +3,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class PasswordEncoderConfiguration { @Bean - BCryptPasswordEncoder passwordEncoder() { + PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } diff --git a/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java b/src/main/java/com/study/tdd/infrastructure/jwt/JwtTokenIssuer.java similarity index 56% rename from src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java rename to src/main/java/com/study/tdd/infrastructure/jwt/JwtTokenIssuer.java index bc88265..ec04260 100644 --- a/src/main/java/com/study/tdd/infrastructure/jwt/JwtComposer.java +++ b/src/main/java/com/study/tdd/infrastructure/jwt/JwtTokenIssuer.java @@ -2,6 +2,8 @@ import java.util.UUID; +import com.study.tdd.application.security.TokenIssuer; +import com.study.tdd.application.security.TokenScope; import com.study.tdd.infrastructure.config.JwtKeyHolder; import io.jsonwebtoken.Jwts; @@ -10,20 +12,21 @@ import org.springframework.stereotype.Component; @Component -public class JwtComposer { +public class JwtTokenIssuer implements TokenIssuer { private final JwtKeyHolder jwtKeyHolder; @Autowired - public JwtComposer(JwtKeyHolder jwtKeyHolder) { + public JwtTokenIssuer(JwtKeyHolder jwtKeyHolder) { this.jwtKeyHolder = jwtKeyHolder; } - public String composeToken(UUID userId, String scope) { + @Override + public String issueToken(UUID subjectId, TokenScope scope) { return Jwts .builder() - .subject(userId.toString()) - .claim("scp", scope) + .subject(subjectId.toString()) + .claim("scp", scope.value()) .signWith(jwtKeyHolder.key()) .compact(); } From 051e6473d5d813be121735c307a9b757f4c72339 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 12:19:00 +0900 Subject: [PATCH 13/31] docs: add seller profile query API test scenarios --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0bca31f..adeff72 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,12 @@ - password 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다. - email 속성에 이미 존재하는 이메일 주소가 지정되면 400 Bad Request 상태코드를 반환한다. - username 속성에 이미 존재하는 사용자 이름이 지정되면 400 Bad Request 상태코드를 반환한다. -- 비밀번호를 올바르게 암호화한다. \ No newline at end of file +- 비밀번호를 올바르게 암호화한다. + +# 판매자 정보 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다. +- 서로 다른 판매자의 식별자는 서로 다르다. +- 같은 판매자의 식별자는 항상 같아야 한다. +- 판매자의 기본 정보가 올바르게 설정된다. \ No newline at end of file From 0fb3fcba3bc5f0a50784265195f60c650ce96b2f Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 13:45:51 +0900 Subject: [PATCH 14/31] feat: implement seller profile query API --- build.gradle | 47 ++-- .../api/controller/SellerMeController.java | 35 +++ .../api/controller/response/SellerMeView.java | 11 + .../config/JwtConfiguration.java | 7 + .../config/SecurityConfiguration.java | 7 +- .../study/tdd/api/seller/me/GET_specs.java | 209 ++++++++++++++++++ 6 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/study/tdd/api/controller/SellerMeController.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/SellerMeView.java create mode 100644 src/test/java/com/study/tdd/api/seller/me/GET_specs.java diff --git a/build.gradle b/build.gradle index 0fce1a4..a4b31f3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,40 +1,41 @@ plugins { - id 'java' - id 'org.springframework.boot' version '4.1.0' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '4.1.0' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.study' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-resttestclient' - testImplementation 'org.springframework.boot:spring-boot-restclient' - testCompileOnly 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testAnnotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-resttestclient' + testImplementation 'org.springframework.boot:spring-boot-restclient' + testCompileOnly 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testAnnotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/com/study/tdd/api/controller/SellerMeController.java b/src/main/java/com/study/tdd/api/controller/SellerMeController.java new file mode 100644 index 0000000..6e3d3e9 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerMeController.java @@ -0,0 +1,35 @@ +package com.study.tdd.api.controller; + +import java.security.Principal; +import java.util.UUID; + +import com.study.tdd.api.controller.response.SellerMeView; +import com.study.tdd.domain.Seller; +import com.study.tdd.infrastructure.persistence.SellerRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SellerMeController { + + private final SellerRepository repository; + + @Autowired + public SellerMeController(SellerRepository repository) { + this.repository = repository; + } + + @GetMapping("/seller/me") + SellerMeView me(Principal user) { + UUID id = UUID.fromString(user.getName()); + Seller seller = repository.findById(id).orElseThrow(); + return new SellerMeView( + id, + seller.getEmail(), + seller.getUsername(), + seller.getContactEmail() + ); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/response/SellerMeView.java b/src/main/java/com/study/tdd/api/controller/response/SellerMeView.java new file mode 100644 index 0000000..5b00bb4 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/SellerMeView.java @@ -0,0 +1,11 @@ +package com.study.tdd.api.controller.response; + +import java.util.UUID; + +public record SellerMeView( + UUID id, + String email, + String username, + String contactEmail +) { +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java index dab47fe..ef9390b 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java @@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; @Configuration public class JwtConfiguration { @@ -17,4 +19,9 @@ JwtKeyHolder jwtKeyHolder(@Value("${security.jwt.secret}") String secret) { SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); return new JwtKeyHolder(key); } + + @Bean + JwtDecoder jwtDecoder(JwtKeyHolder jwtKeyHolder) { + return NimbusJwtDecoder.withSecretKey(jwtKeyHolder.key()).build(); + } } diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index 54faea6..c37dc4d 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -4,19 +4,24 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.web.SecurityFilterChain; +import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope; + @Configuration public class SecurityConfiguration { @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception { return http .csrf(AbstractHttpConfigurer::disable) + .oauth2ResourceServer(configurer -> configurer.jwt(jwt -> jwt.decoder(jwtDecoder))) .authorizeHttpRequests(requests -> requests .requestMatchers("/seller/signUp").permitAll() .requestMatchers("/seller/issueToken").permitAll() .requestMatchers("/shopper/signUp").permitAll() + .requestMatchers("/seller/me").access(hasScope("seller")) .anyRequest().authenticated() ) .build(); diff --git a/src/test/java/com/study/tdd/api/seller/me/GET_specs.java b/src/test/java/com/study/tdd/api/seller/me/GET_specs.java new file mode 100644 index 0000000..49b33b6 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/me/GET_specs.java @@ -0,0 +1,209 @@ +package com.study.tdd.api.seller.me; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.api.controller.response.SellerMeView; +import com.study.tdd.application.command.CreateSellerCommand; +import com.study.tdd.application.query.IssueSellerToken; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.http.ResponseEntity; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.RequestEntity.get; +import static com.study.tdd.support.EmailGenerator.generateEmail; +import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.UsernameGenerator.generateUsername; + +/** + * {@code GET /seller/me} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

접근 토큰(access token)으로 인증한 판매자 자신의 정보를 반환하는 계약을 다룬다. + * 토큰은 회원가입 → 토큰 발급을 거쳐 실제로 발행된 것을 사용하며, HTTP 입출력만 관찰한다.

+ * + *

테스트 시나리오

+ *
    + *
  • 올바르게 요청하면 200 OK 상태코드를 반환한다.
  • + *
  • 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다.
  • + *
  • 서로 다른 판매자의 식별자는 서로 다르다.
  • + *
  • 같은 판매자의 식별자는 항상 같아야 한다.
  • + *
  • 판매자의 기본 정보가 올바르게 설정된다.
  • + *
+ */ +@TddApiTest +@DisplayName("GET /seller/me") +public class GET_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + password, + generateEmail() + ), + Void.class + ); + + String token = issueToken(client, email, password); + + // Act + ResponseEntity response = client.exchange( + get("/seller/me") + .header("Authorization", "Bearer " + token) + .build(), + SellerMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 액세스_토큰을_사용하지_않으면_401_Unauthorized_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Act + ResponseEntity response = client.getForEntity( + "/seller/me", + SellerMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + void 서로_다른_판매자의_식별자는_서로_다르다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email1 = generateEmail(); + String password1 = generatePassword(); + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email1, + generateUsername(), + password1, + generateEmail() + ), + Void.class + ); + String token1 = issueToken(client, email1, password1); + + String email2 = generateEmail(); + String password2 = generatePassword(); + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email2, + generateUsername(), + password2, + generateEmail() + ), + Void.class + ); + String token2 = issueToken(client, email2, password2); + + // Act + SellerMeView seller1 = getSellerMe(client, token1); + SellerMeView seller2 = getSellerMe(client, token2); + + // Assert + assertThat(seller1.id()).isNotEqualTo(seller2.id()); + } + + @Test + void 같은_판매자의_식별자는_항상_같다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + password, + generateEmail() + ), + Void.class + ); + + String token1 = issueToken(client, email, password); + String token2 = issueToken(client, email, password); + + // Act + SellerMeView seller1 = getSellerMe(client, token1); + SellerMeView seller2 = getSellerMe(client, token2); + + // Assert + assertThat(seller1.id()).isEqualTo(seller2.id()); + } + + @Test + void 판매자의_기본_정보가_올바르게_설정된다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String username = generateUsername(); + String password = generatePassword(); + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + username, + password, + generateEmail() + ), + Void.class + ); + + String token = issueToken(client, email, password); + + // Act + SellerMeView actual = getSellerMe(client, token); + + // Assert + assertThat(actual.email()).isEqualTo(email); + assertThat(actual.username()).isEqualTo(username); + } + + private static String issueToken( + TestRestTemplate client, + String email, + String password + ) { + AccessTokenCarrier carrier = client.postForObject( + "/seller/issueToken", + new IssueSellerToken(email, password), + AccessTokenCarrier.class + ); + return requireNonNull(carrier).accessToken(); + } + + private static SellerMeView getSellerMe(TestRestTemplate client, String token) { + ResponseEntity response = client.exchange( + get("/seller/me") + .header("Authorization", "Bearer " + token) + .build(), + SellerMeView.class + ); + return requireNonNull(response.getBody()); + } +} From 35104b786afddca20bd88fa336b150507c9241c4 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 14:16:00 +0900 Subject: [PATCH 15/31] refactor: route seller profile lookup through an application service --- .../api/controller/SellerMeController.java | 10 ++++---- .../tdd/application/SellerProfileService.java | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/study/tdd/application/SellerProfileService.java diff --git a/src/main/java/com/study/tdd/api/controller/SellerMeController.java b/src/main/java/com/study/tdd/api/controller/SellerMeController.java index 6e3d3e9..957b732 100644 --- a/src/main/java/com/study/tdd/api/controller/SellerMeController.java +++ b/src/main/java/com/study/tdd/api/controller/SellerMeController.java @@ -4,8 +4,8 @@ import java.util.UUID; import com.study.tdd.api.controller.response.SellerMeView; +import com.study.tdd.application.SellerProfileService; import com.study.tdd.domain.Seller; -import com.study.tdd.infrastructure.persistence.SellerRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; @@ -14,17 +14,17 @@ @RestController public class SellerMeController { - private final SellerRepository repository; + private final SellerProfileService sellerProfileService; @Autowired - public SellerMeController(SellerRepository repository) { - this.repository = repository; + public SellerMeController(SellerProfileService sellerProfileService) { + this.sellerProfileService = sellerProfileService; } @GetMapping("/seller/me") SellerMeView me(Principal user) { UUID id = UUID.fromString(user.getName()); - Seller seller = repository.findById(id).orElseThrow(); + Seller seller = sellerProfileService.findSeller(id).orElseThrow(); return new SellerMeView( id, seller.getEmail(), diff --git a/src/main/java/com/study/tdd/application/SellerProfileService.java b/src/main/java/com/study/tdd/application/SellerProfileService.java new file mode 100644 index 0000000..406579c --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerProfileService.java @@ -0,0 +1,25 @@ +package com.study.tdd.application; + +import java.util.Optional; +import java.util.UUID; + +import com.study.tdd.domain.Seller; +import com.study.tdd.infrastructure.persistence.SellerRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class SellerProfileService { + + private final SellerRepository repository; + + @Autowired + public SellerProfileService(SellerRepository repository) { + this.repository = repository; + } + + public Optional findSeller(UUID sellerId) { + return repository.findById(sellerId); + } +} From 9c6e8173d1af776a59f9acdc898a52d87288f992 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 14:57:53 +0900 Subject: [PATCH 16/31] docs: add shopper profile query API test scenarios --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index adeff72..2895601 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,12 @@ - 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다. - 서로 다른 판매자의 식별자는 서로 다르다. - 같은 판매자의 식별자는 항상 같아야 한다. -- 판매자의 기본 정보가 올바르게 설정된다. \ No newline at end of file +- 판매자의 기본 정보가 올바르게 설정된다. + +# 구매자 정보 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다. +- 서로 다른 구매자의 식별자는 서로 다르다. +- 같은 구매자의 식별자는 항상 같아야 한다. +- 구매자의 기본 정보가 올바르게 설정된다. \ No newline at end of file From 17837ef86e55b476f7f3f4f0b5d47a79d8ef3c1d Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 14:58:26 +0900 Subject: [PATCH 17/31] feat: implement shopper token issuance and profile query API --- .../ShopperIssueTokenController.java | 31 +++ .../api/controller/ShopperMeController.java | 34 +++ .../request/IssueShopperTokenRequest.java | 10 + .../controller/response/ShopperMeView.java | 10 + .../application/ShopperIssueTokenService.java | 41 ++++ .../application/ShopperProfileService.java | 25 +++ .../application/query/IssueShopperToken.java | 4 + .../tdd/application/security/TokenScope.java | 3 +- .../config/SecurityConfiguration.java | 2 + .../study/tdd/api/shopper/me/GET_specs.java | 205 ++++++++++++++++++ 10 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/study/tdd/api/controller/ShopperIssueTokenController.java create mode 100644 src/main/java/com/study/tdd/api/controller/ShopperMeController.java create mode 100644 src/main/java/com/study/tdd/api/controller/request/IssueShopperTokenRequest.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/ShopperMeView.java create mode 100644 src/main/java/com/study/tdd/application/ShopperIssueTokenService.java create mode 100644 src/main/java/com/study/tdd/application/ShopperProfileService.java create mode 100644 src/main/java/com/study/tdd/application/query/IssueShopperToken.java create mode 100644 src/test/java/com/study/tdd/api/shopper/me/GET_specs.java diff --git a/src/main/java/com/study/tdd/api/controller/ShopperIssueTokenController.java b/src/main/java/com/study/tdd/api/controller/ShopperIssueTokenController.java new file mode 100644 index 0000000..f7e5a1e --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/ShopperIssueTokenController.java @@ -0,0 +1,31 @@ +package com.study.tdd.api.controller; + +import com.study.tdd.api.controller.request.IssueShopperTokenRequest; +import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.application.ShopperIssueTokenService; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ShopperIssueTokenController { + + private final ShopperIssueTokenService shopperIssueTokenService; + + @Autowired + public ShopperIssueTokenController(ShopperIssueTokenService shopperIssueTokenService) { + this.shopperIssueTokenService = shopperIssueTokenService; + } + + @PostMapping("/shopper/issueToken") + ResponseEntity issueToken(@RequestBody IssueShopperTokenRequest request) { + return shopperIssueTokenService + .issueToken(request.toQuery()) + .map(AccessTokenCarrier::new) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.badRequest().build()); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/ShopperMeController.java b/src/main/java/com/study/tdd/api/controller/ShopperMeController.java new file mode 100644 index 0000000..21afafe --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/ShopperMeController.java @@ -0,0 +1,34 @@ +package com.study.tdd.api.controller; + +import java.security.Principal; +import java.util.UUID; + +import com.study.tdd.api.controller.response.ShopperMeView; +import com.study.tdd.application.ShopperProfileService; +import com.study.tdd.domain.Shopper; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ShopperMeController { + + private final ShopperProfileService shopperProfileService; + + @Autowired + public ShopperMeController(ShopperProfileService shopperProfileService) { + this.shopperProfileService = shopperProfileService; + } + + @GetMapping("/shopper/me") + ShopperMeView me(Principal user) { + UUID id = UUID.fromString(user.getName()); + Shopper shopper = shopperProfileService.findShopper(id).orElseThrow(); + return new ShopperMeView( + id, + shopper.getEmail(), + shopper.getUsername() + ); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/request/IssueShopperTokenRequest.java b/src/main/java/com/study/tdd/api/controller/request/IssueShopperTokenRequest.java new file mode 100644 index 0000000..59fe30d --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/request/IssueShopperTokenRequest.java @@ -0,0 +1,10 @@ +package com.study.tdd.api.controller.request; + +import com.study.tdd.application.query.IssueShopperToken; + +public record IssueShopperTokenRequest(String email, String password) { + + public IssueShopperToken toQuery() { + return new IssueShopperToken(email, password); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/response/ShopperMeView.java b/src/main/java/com/study/tdd/api/controller/response/ShopperMeView.java new file mode 100644 index 0000000..f27499a --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/ShopperMeView.java @@ -0,0 +1,10 @@ +package com.study.tdd.api.controller.response; + +import java.util.UUID; + +public record ShopperMeView( + UUID id, + String email, + String username +) { +} diff --git a/src/main/java/com/study/tdd/application/ShopperIssueTokenService.java b/src/main/java/com/study/tdd/application/ShopperIssueTokenService.java new file mode 100644 index 0000000..d1b81d0 --- /dev/null +++ b/src/main/java/com/study/tdd/application/ShopperIssueTokenService.java @@ -0,0 +1,41 @@ +package com.study.tdd.application; + +import java.util.Optional; + +import com.study.tdd.application.query.IssueShopperToken; +import com.study.tdd.application.security.TokenIssuer; +import com.study.tdd.application.security.TokenScope; +import com.study.tdd.infrastructure.persistence.ShopperRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class ShopperIssueTokenService { + + private final PasswordEncoder passwordEncoder; + private final ShopperRepository repository; + private final TokenIssuer tokenIssuer; + + @Autowired + public ShopperIssueTokenService( + PasswordEncoder passwordEncoder, + ShopperRepository repository, + TokenIssuer tokenIssuer + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + this.tokenIssuer = tokenIssuer; + } + + public Optional issueToken(IssueShopperToken query) { + return repository + .findByEmail(query.email()) + .filter(shopper -> passwordEncoder.matches( + query.password(), + shopper.getHashedPassword() + )) + .map(shopper -> tokenIssuer.issueToken(shopper.getId(), TokenScope.SHOPPER)); + } +} diff --git a/src/main/java/com/study/tdd/application/ShopperProfileService.java b/src/main/java/com/study/tdd/application/ShopperProfileService.java new file mode 100644 index 0000000..e0291e2 --- /dev/null +++ b/src/main/java/com/study/tdd/application/ShopperProfileService.java @@ -0,0 +1,25 @@ +package com.study.tdd.application; + +import java.util.Optional; +import java.util.UUID; + +import com.study.tdd.domain.Shopper; +import com.study.tdd.infrastructure.persistence.ShopperRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class ShopperProfileService { + + private final ShopperRepository repository; + + @Autowired + public ShopperProfileService(ShopperRepository repository) { + this.repository = repository; + } + + public Optional findShopper(UUID shopperId) { + return repository.findById(shopperId); + } +} diff --git a/src/main/java/com/study/tdd/application/query/IssueShopperToken.java b/src/main/java/com/study/tdd/application/query/IssueShopperToken.java new file mode 100644 index 0000000..eed3517 --- /dev/null +++ b/src/main/java/com/study/tdd/application/query/IssueShopperToken.java @@ -0,0 +1,4 @@ +package com.study.tdd.application.query; + +public record IssueShopperToken(String email, String password) { +} diff --git a/src/main/java/com/study/tdd/application/security/TokenScope.java b/src/main/java/com/study/tdd/application/security/TokenScope.java index cf0a8c1..4bc47a3 100644 --- a/src/main/java/com/study/tdd/application/security/TokenScope.java +++ b/src/main/java/com/study/tdd/application/security/TokenScope.java @@ -2,7 +2,8 @@ public enum TokenScope { - SELLER("seller"); + SELLER("seller"), + SHOPPER("shopper"); private final String value; diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index c37dc4d..2c275aa 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -21,7 +21,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder .requestMatchers("/seller/signUp").permitAll() .requestMatchers("/seller/issueToken").permitAll() .requestMatchers("/shopper/signUp").permitAll() + .requestMatchers("/shopper/issueToken").permitAll() .requestMatchers("/seller/me").access(hasScope("seller")) + .requestMatchers("/shopper/me").access(hasScope("shopper")) .anyRequest().authenticated() ) .build(); diff --git a/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java b/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java new file mode 100644 index 0000000..6e2fc02 --- /dev/null +++ b/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java @@ -0,0 +1,205 @@ +package com.study.tdd.api.shopper.me; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.api.controller.response.ShopperMeView; +import com.study.tdd.application.command.CreateShopperCommand; +import com.study.tdd.application.query.IssueShopperToken; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.http.ResponseEntity; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.RequestEntity.get; +import static com.study.tdd.support.EmailGenerator.generateEmail; +import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.UsernameGenerator.generateUsername; + +/** + * {@code GET /shopper/me} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

접근 토큰(access token)으로 인증한 구매자 자신의 정보를 반환하는 계약을 다룬다. + * 토큰은 회원가입 → 토큰 발급을 거쳐 실제로 발행된 것을 사용하며, HTTP 입출력만 관찰한다. + * 판매자와 정책이 같지만 연락 이메일(contactEmail) 속성이 없다는 점이 다르다.

+ * + *

테스트 시나리오

+ *
    + *
  • 올바르게 요청하면 200 OK 상태코드를 반환한다.
  • + *
  • 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다.
  • + *
  • 서로 다른 구매자의 식별자는 서로 다르다.
  • + *
  • 같은 구매자의 식별자는 항상 같아야 한다.
  • + *
  • 구매자의 기본 정보가 올바르게 설정된다.
  • + *
+ */ +@TddApiTest +@DisplayName("GET /shopper/me") +public class GET_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + password + ), + Void.class + ); + + String token = issueToken(client, email, password); + + // Act + ResponseEntity response = client.exchange( + get("/shopper/me") + .header("Authorization", "Bearer " + token) + .build(), + ShopperMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 액세스_토큰을_사용하지_않으면_401_Unauthorized_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Act + ResponseEntity response = client.getForEntity( + "/shopper/me", + ShopperMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + void 서로_다른_구매자의_식별자는_서로_다르다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email1 = generateEmail(); + String password1 = generatePassword(); + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email1, + generateUsername(), + password1 + ), + Void.class + ); + String token1 = issueToken(client, email1, password1); + + String email2 = generateEmail(); + String password2 = generatePassword(); + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email2, + generateUsername(), + password2 + ), + Void.class + ); + String token2 = issueToken(client, email2, password2); + + // Act + ShopperMeView shopper1 = getShopperMe(client, token1); + ShopperMeView shopper2 = getShopperMe(client, token2); + + // Assert + assertThat(shopper1.id()).isNotEqualTo(shopper2.id()); + } + + @Test + void 같은_구매자의_식별자는_항상_같다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + password + ), + Void.class + ); + + String token1 = issueToken(client, email, password); + String token2 = issueToken(client, email, password); + + // Act + ShopperMeView shopper1 = getShopperMe(client, token1); + ShopperMeView shopper2 = getShopperMe(client, token2); + + // Assert + assertThat(shopper1.id()).isEqualTo(shopper2.id()); + } + + @Test + void 구매자의_기본_정보가_올바르게_설정된다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String username = generateUsername(); + String password = generatePassword(); + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + username, + password + ), + Void.class + ); + + String token = issueToken(client, email, password); + + // Act + ShopperMeView actual = getShopperMe(client, token); + + // Assert + assertThat(actual.email()).isEqualTo(email); + assertThat(actual.username()).isEqualTo(username); + } + + private static String issueToken( + TestRestTemplate client, + String email, + String password + ) { + AccessTokenCarrier carrier = client.postForObject( + "/shopper/issueToken", + new IssueShopperToken(email, password), + AccessTokenCarrier.class + ); + return requireNonNull(carrier).accessToken(); + } + + private static ShopperMeView getShopperMe(TestRestTemplate client, String token) { + ResponseEntity response = client.exchange( + get("/shopper/me") + .header("Authorization", "Bearer " + token) + .build(), + ShopperMeView.class + ); + return requireNonNull(response.getBody()); + } +} From b12fbc7fb27baf1bcefcb79e8390dd5340a95765 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 15:20:09 +0900 Subject: [PATCH 18/31] refactor: extract TestFixture to remove duplication in me specs --- .../java/com/study/tdd/api/TddApiTest.java | 5 +- .../java/com/study/tdd/api/TestFixture.java | 128 ++++++++++++++++++ .../tdd/api/TestFixtureConfiguration.java | 14 ++ .../study/tdd/api/seller/me/GET_specs.java | 125 ++++------------- .../study/tdd/api/shopper/me/GET_specs.java | 118 ++++------------ 5 files changed, 200 insertions(+), 190 deletions(-) create mode 100644 src/test/java/com/study/tdd/api/TestFixture.java create mode 100644 src/test/java/com/study/tdd/api/TestFixtureConfiguration.java diff --git a/src/test/java/com/study/tdd/api/TddApiTest.java b/src/test/java/com/study/tdd/api/TddApiTest.java index 7998722..b373d0a 100644 --- a/src/test/java/com/study/tdd/api/TddApiTest.java +++ b/src/test/java/com/study/tdd/api/TddApiTest.java @@ -22,6 +22,8 @@ * 필터·시큐리티·직렬화까지 포함한 진짜 HTTP 경로를 검증한다. *
  • {@link TestPasswordEncoderConfiguration} — 운영용 인코더 대신 빠른 인코더를 * {@code @Primary}로 주입해 테스트 속도를 확보한다.
  • + *
  • {@link TestFixtureConfiguration} — "가입 → 토큰 발급 → 인증된 요청" 준비 절차를 + * 한 줄로 끌어올린 {@link TestFixture}를 테스트마다 새로({@code prototype}) 주입한다.
  • *
  • {@code @AutoConfigureTestRestTemplate} — Spring Boot 4.1에서 분리된 * {@code TestRestTemplate} 빈을 등록한다.
  • * @@ -31,7 +33,8 @@ @SpringBootTest( classes = { TddApplication.class, - TestPasswordEncoderConfiguration.class + TestPasswordEncoderConfiguration.class, + TestFixtureConfiguration.class }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT ) diff --git a/src/test/java/com/study/tdd/api/TestFixture.java b/src/test/java/com/study/tdd/api/TestFixture.java new file mode 100644 index 0000000..b3b46f6 --- /dev/null +++ b/src/test/java/com/study/tdd/api/TestFixture.java @@ -0,0 +1,128 @@ +package com.study.tdd.api; + +import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.api.controller.response.SellerMeView; +import com.study.tdd.api.controller.response.ShopperMeView; +import com.study.tdd.application.command.CreateSellerCommand; +import com.study.tdd.application.command.CreateShopperCommand; +import com.study.tdd.application.query.IssueSellerToken; +import com.study.tdd.application.query.IssueShopperToken; + +import org.springframework.boot.restclient.RestTemplateBuilder; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.core.env.Environment; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static java.util.Objects.requireNonNull; +import static com.study.tdd.support.EmailGenerator.generateEmail; +import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.UsernameGenerator.generateUsername; + +/** + * API 명세 테스트를 위한 시나리오 픽스처(fixture). + * + *

    "가입 → 토큰 발급 → 인증된 요청"처럼 여러 스펙이 반복하던 준비(arrange) 절차를 + * 도메인 언어의 메서드 하나로 끌어올린다. 각 스펙은 저수준 HTTP 호출을 나열하는 대신 + * {@code fixture.createShopperThenIssueToken()}처럼 의도만 드러내는 한 줄로 준비를 마친다.

    + * + *

    픽스처는 {@code @Scope("prototype")}으로 테스트마다 새로 만들어지며, 자신만의 + * {@link TestRestTemplate}을 소유한다. 따라서 {@link #setShopperAsDefaultUser}처럼 + * 클라이언트에 기본 인증 헤더를 심는 조작이 다른 테스트로 새지 않는다.

    + */ +public record TestFixture(TestRestTemplate client) { + + public static TestFixture create(Environment environment) { + int port = environment.getRequiredProperty("local.server.port", Integer.class); + var builder = new RestTemplateBuilder().rootUri("http://localhost:" + port); + return new TestFixture(new TestRestTemplate(builder)); + } + + public void createShopper(String email, String username, String password) { + var command = new CreateShopperCommand(email, username, password); + ensureSuccessful( + client.postForEntity("/shopper/signUp", command, Void.class), + command + ); + } + + public void createSeller( + String email, + String username, + String password, + String contactEmail + ) { + var command = new CreateSellerCommand(email, username, password, contactEmail); + ensureSuccessful( + client.postForEntity("/seller/signUp", command, Void.class), + command + ); + } + + public String issueShopperToken(String email, String password) { + AccessTokenCarrier carrier = client.postForObject( + "/shopper/issueToken", + new IssueShopperToken(email, password), + AccessTokenCarrier.class + ); + return requireNonNull(carrier).accessToken(); + } + + public String issueSellerToken(String email, String password) { + AccessTokenCarrier carrier = client.postForObject( + "/seller/issueToken", + new IssueSellerToken(email, password), + AccessTokenCarrier.class + ); + return requireNonNull(carrier).accessToken(); + } + + public String createShopperThenIssueToken() { + String email = generateEmail(); + String password = generatePassword(); + createShopper(email, generateUsername(), password); + return issueShopperToken(email, password); + } + + public String createSellerThenIssueToken() { + String email = generateEmail(); + String password = generatePassword(); + createSeller(email, generateUsername(), password, generateEmail()); + return issueSellerToken(email, password); + } + + public void setShopperAsDefaultUser(String email, String password) { + setDefaultAuthorization("Bearer " + issueShopperToken(email, password)); + } + + public void setSellerAsDefaultUser(String email, String password) { + setDefaultAuthorization("Bearer " + issueSellerToken(email, password)); + } + + public ShopperMeView getShopper() { + return client.getForObject("/shopper/me", ShopperMeView.class); + } + + public SellerMeView getSeller() { + return client.getForObject("/seller/me", SellerMeView.class); + } + + private void setDefaultAuthorization(String authorization) { + RestTemplate template = client.getRestTemplate(); + template.getInterceptors().add(0, (request, body, execution) -> { + if (!request.getHeaders().containsHeader("Authorization")) { + request.getHeaders().add("Authorization", authorization); + } + return execution.execute(request, body); + }); + } + + private void ensureSuccessful(ResponseEntity response, Object request) { + if (!response.getStatusCode().is2xxSuccessful()) { + throw new RuntimeException( + "Request with " + request + + " failed with status code " + response.getStatusCode() + ); + } + } +} diff --git a/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java b/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java new file mode 100644 index 0000000..c0c21b7 --- /dev/null +++ b/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java @@ -0,0 +1,14 @@ +package com.study.tdd.api; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; +import org.springframework.core.env.Environment; + +public class TestFixtureConfiguration { + + @Bean + @Scope("prototype") + TestFixture testFixture(Environment environment) { + return TestFixture.create(environment); + } +} diff --git a/src/test/java/com/study/tdd/api/seller/me/GET_specs.java b/src/test/java/com/study/tdd/api/seller/me/GET_specs.java index 49b33b6..d9992a6 100644 --- a/src/test/java/com/study/tdd/api/seller/me/GET_specs.java +++ b/src/test/java/com/study/tdd/api/seller/me/GET_specs.java @@ -1,15 +1,12 @@ package com.study.tdd.api.seller.me; import com.study.tdd.api.TddApiTest; -import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.api.TestFixture; import com.study.tdd.api.controller.response.SellerMeView; -import com.study.tdd.application.command.CreateSellerCommand; -import com.study.tdd.application.query.IssueSellerToken; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.resttestclient.TestRestTemplate; import org.springframework.http.ResponseEntity; import static java.util.Objects.requireNonNull; @@ -25,6 +22,9 @@ *

    접근 토큰(access token)으로 인증한 판매자 자신의 정보를 반환하는 계약을 다룬다. * 토큰은 회원가입 → 토큰 발급을 거쳐 실제로 발행된 것을 사용하며, HTTP 입출력만 관찰한다.

    * + *

    준비(arrange) 절차는 {@link TestFixture}로 끌어올려, 각 케이스는 저수준 HTTP 호출이 + * 아니라 의도를 드러내는 한 줄로 사용자를 만든다.

    + * *

    테스트 시나리오

    *
      *
    • 올바르게 요청하면 200 OK 상태코드를 반환한다.
    • @@ -40,27 +40,13 @@ public class GET_specs { @Test void 올바르게_요청하면_200_OK_상태코드를_반환한다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange - String email = generateEmail(); - String password = generatePassword(); - - client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - email, - generateUsername(), - password, - generateEmail() - ), - Void.class - ); - - String token = issueToken(client, email, password); + String token = fixture.createSellerThenIssueToken(); // Act - ResponseEntity response = client.exchange( + ResponseEntity response = fixture.client().exchange( get("/seller/me") .header("Authorization", "Bearer " + token) .build(), @@ -73,10 +59,10 @@ public class GET_specs { @Test void 액세스_토큰을_사용하지_않으면_401_Unauthorized_상태코드를_반환한다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Act - ResponseEntity response = client.getForEntity( + ResponseEntity response = fixture.client().getForEntity( "/seller/me", SellerMeView.class ); @@ -87,40 +73,15 @@ public class GET_specs { @Test void 서로_다른_판매자의_식별자는_서로_다르다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange - String email1 = generateEmail(); - String password1 = generatePassword(); - client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - email1, - generateUsername(), - password1, - generateEmail() - ), - Void.class - ); - String token1 = issueToken(client, email1, password1); - - String email2 = generateEmail(); - String password2 = generatePassword(); - client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - email2, - generateUsername(), - password2, - generateEmail() - ), - Void.class - ); - String token2 = issueToken(client, email2, password2); + String token1 = fixture.createSellerThenIssueToken(); + String token2 = fixture.createSellerThenIssueToken(); // Act - SellerMeView seller1 = getSellerMe(client, token1); - SellerMeView seller2 = getSellerMe(client, token2); + SellerMeView seller1 = getSellerMe(fixture, token1); + SellerMeView seller2 = getSellerMe(fixture, token2); // Assert assertThat(seller1.id()).isNotEqualTo(seller2.id()); @@ -128,28 +89,19 @@ public class GET_specs { @Test void 같은_판매자의_식별자는_항상_같다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange String email = generateEmail(); String password = generatePassword(); - client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - email, - generateUsername(), - password, - generateEmail() - ), - Void.class - ); + fixture.createSeller(email, generateUsername(), password, generateEmail()); - String token1 = issueToken(client, email, password); - String token2 = issueToken(client, email, password); + String token1 = fixture.issueSellerToken(email, password); + String token2 = fixture.issueSellerToken(email, password); // Act - SellerMeView seller1 = getSellerMe(client, token1); - SellerMeView seller2 = getSellerMe(client, token2); + SellerMeView seller1 = getSellerMe(fixture, token1); + SellerMeView seller2 = getSellerMe(fixture, token2); // Assert assertThat(seller1.id()).isEqualTo(seller2.id()); @@ -157,48 +109,27 @@ public class GET_specs { @Test void 판매자의_기본_정보가_올바르게_설정된다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange String email = generateEmail(); String username = generateUsername(); String password = generatePassword(); - client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - email, - username, - password, - generateEmail() - ), - Void.class - ); - - String token = issueToken(client, email, password); + String contactEmail = generateEmail(); + fixture.createSeller(email, username, password, contactEmail); + fixture.setSellerAsDefaultUser(email, password); // Act - SellerMeView actual = getSellerMe(client, token); + SellerMeView actual = fixture.getSeller(); // Assert assertThat(actual.email()).isEqualTo(email); assertThat(actual.username()).isEqualTo(username); + assertThat(actual.contactEmail()).isEqualTo(contactEmail); } - private static String issueToken( - TestRestTemplate client, - String email, - String password - ) { - AccessTokenCarrier carrier = client.postForObject( - "/seller/issueToken", - new IssueSellerToken(email, password), - AccessTokenCarrier.class - ); - return requireNonNull(carrier).accessToken(); - } - - private static SellerMeView getSellerMe(TestRestTemplate client, String token) { - ResponseEntity response = client.exchange( + private static SellerMeView getSellerMe(TestFixture fixture, String token) { + ResponseEntity response = fixture.client().exchange( get("/seller/me") .header("Authorization", "Bearer " + token) .build(), diff --git a/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java b/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java index 6e2fc02..b1d9581 100644 --- a/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java +++ b/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java @@ -1,15 +1,12 @@ package com.study.tdd.api.shopper.me; import com.study.tdd.api.TddApiTest; -import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.api.TestFixture; import com.study.tdd.api.controller.response.ShopperMeView; -import com.study.tdd.application.command.CreateShopperCommand; -import com.study.tdd.application.query.IssueShopperToken; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.resttestclient.TestRestTemplate; import org.springframework.http.ResponseEntity; import static java.util.Objects.requireNonNull; @@ -26,6 +23,9 @@ * 토큰은 회원가입 → 토큰 발급을 거쳐 실제로 발행된 것을 사용하며, HTTP 입출력만 관찰한다. * 판매자와 정책이 같지만 연락 이메일(contactEmail) 속성이 없다는 점이 다르다.

      * + *

      준비(arrange) 절차는 {@link TestFixture}로 끌어올려, 각 케이스는 저수준 HTTP 호출이 + * 아니라 의도를 드러내는 한 줄로 사용자를 만든다.

      + * *

      테스트 시나리오

      *
        *
      • 올바르게 요청하면 200 OK 상태코드를 반환한다.
      • @@ -41,26 +41,13 @@ public class GET_specs { @Test void 올바르게_요청하면_200_OK_상태코드를_반환한다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange - String email = generateEmail(); - String password = generatePassword(); - - client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - email, - generateUsername(), - password - ), - Void.class - ); - - String token = issueToken(client, email, password); + String token = fixture.createShopperThenIssueToken(); // Act - ResponseEntity response = client.exchange( + ResponseEntity response = fixture.client().exchange( get("/shopper/me") .header("Authorization", "Bearer " + token) .build(), @@ -73,10 +60,10 @@ public class GET_specs { @Test void 액세스_토큰을_사용하지_않으면_401_Unauthorized_상태코드를_반환한다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Act - ResponseEntity response = client.getForEntity( + ResponseEntity response = fixture.client().getForEntity( "/shopper/me", ShopperMeView.class ); @@ -87,38 +74,15 @@ public class GET_specs { @Test void 서로_다른_구매자의_식별자는_서로_다르다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange - String email1 = generateEmail(); - String password1 = generatePassword(); - client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - email1, - generateUsername(), - password1 - ), - Void.class - ); - String token1 = issueToken(client, email1, password1); - - String email2 = generateEmail(); - String password2 = generatePassword(); - client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - email2, - generateUsername(), - password2 - ), - Void.class - ); - String token2 = issueToken(client, email2, password2); + String token1 = fixture.createShopperThenIssueToken(); + String token2 = fixture.createShopperThenIssueToken(); // Act - ShopperMeView shopper1 = getShopperMe(client, token1); - ShopperMeView shopper2 = getShopperMe(client, token2); + ShopperMeView shopper1 = getShopperMe(fixture, token1); + ShopperMeView shopper2 = getShopperMe(fixture, token2); // Assert assertThat(shopper1.id()).isNotEqualTo(shopper2.id()); @@ -126,27 +90,19 @@ public class GET_specs { @Test void 같은_구매자의_식별자는_항상_같다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange String email = generateEmail(); String password = generatePassword(); - client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - email, - generateUsername(), - password - ), - Void.class - ); + fixture.createShopper(email, generateUsername(), password); - String token1 = issueToken(client, email, password); - String token2 = issueToken(client, email, password); + String token1 = fixture.issueShopperToken(email, password); + String token2 = fixture.issueShopperToken(email, password); // Act - ShopperMeView shopper1 = getShopperMe(client, token1); - ShopperMeView shopper2 = getShopperMe(client, token2); + ShopperMeView shopper1 = getShopperMe(fixture, token1); + ShopperMeView shopper2 = getShopperMe(fixture, token2); // Assert assertThat(shopper1.id()).isEqualTo(shopper2.id()); @@ -154,47 +110,25 @@ public class GET_specs { @Test void 구매자의_기본_정보가_올바르게_설정된다( - @Autowired TestRestTemplate client + @Autowired TestFixture fixture ) { // Arrange String email = generateEmail(); String username = generateUsername(); String password = generatePassword(); - client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - email, - username, - password - ), - Void.class - ); - - String token = issueToken(client, email, password); + fixture.createShopper(email, username, password); + fixture.setShopperAsDefaultUser(email, password); // Act - ShopperMeView actual = getShopperMe(client, token); + ShopperMeView actual = fixture.getShopper(); // Assert assertThat(actual.email()).isEqualTo(email); assertThat(actual.username()).isEqualTo(username); } - private static String issueToken( - TestRestTemplate client, - String email, - String password - ) { - AccessTokenCarrier carrier = client.postForObject( - "/shopper/issueToken", - new IssueShopperToken(email, password), - AccessTokenCarrier.class - ); - return requireNonNull(carrier).accessToken(); - } - - private static ShopperMeView getShopperMe(TestRestTemplate client, String token) { - ResponseEntity response = client.exchange( + private static ShopperMeView getShopperMe(TestFixture fixture, String token) { + ResponseEntity response = fixture.client().exchange( get("/shopper/me") .header("Authorization", "Bearer " + token) .build(), From d1f1a48eef9feb8bbc440180fcb038b1d8f73836 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Tue, 14 Jul 2026 15:44:29 +0900 Subject: [PATCH 19/31] test: introduce declarative invalid-value source annotations --- .../tdd/api/seller/signup/POST_specs.java | 704 +++++++++--------- .../tdd/api/shopper/signup/POST_specs.java | 611 +++++++-------- .../study/tdd/support/InvalidEmailSource.java | 21 + .../tdd/support/InvalidPasswordSource.java | 21 + 4 files changed, 701 insertions(+), 656 deletions(-) create mode 100644 src/test/java/com/study/tdd/support/InvalidEmailSource.java create mode 100644 src/test/java/com/study/tdd/support/InvalidPasswordSource.java diff --git a/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java index bd6ae72..2629d07 100644 --- a/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java +++ b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java @@ -4,10 +4,12 @@ import com.study.tdd.application.command.CreateSellerCommand; import com.study.tdd.domain.Seller; import com.study.tdd.infrastructure.persistence.SellerRepository; +import com.study.tdd.support.InvalidEmailSource; +import com.study.tdd.support.InvalidPasswordSource; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.TestRestTemplate; @@ -34,354 +36,354 @@ @DisplayName("POST /seller/signUp") public class POST_specs { - /** - * 회원가입의 해피 패스(happy path) — 모든 속성이 유효하면 - * 본문 없이 성공을 뜻하는 {@code 204 No Content}를 반환해야 한다. - * - *

        나머지 케이스들이 검증하는 "실패 계약"의 기준점이 되는 기본 명세이다.

        - */ - @Test - void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - generateUsername(), - "password", - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(204); - } - - @Test - void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - null, - generateUsername(), - "password", - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - /** - * 이메일 형식 검증의 대표 명세. 하나의 규칙(정규식)이 막아야 할 여러 경계값을 {@link ValueSource}로 나열해 한 메서드로 검증한다. - * - *

        골뱅이 누락·도메인 누락·TLD 누락 등 "형식은 갖췄지만 무효"인 값들을 모아 검증 로직의 빈틈을 좁힌다. 케이스 추가는 문자열 한 줄로 끝난다.

        - */ - @ParameterizedTest - @ValueSource(strings = { - "invalid-email", - "invalid-email@", - "invalid-email@test", - "invalid-email@test.", - "invalid-email@.com" - }) - void email_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( - String email, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - email, - generateUsername(), - "password", - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @Test - void username_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - null, - "password", - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @ParameterizedTest - @ValueSource(strings = { - "", - "se", - "seller ", - "seller.", - "seller!", - "seller@" - }) - void username_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( - String username, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - username, - "password", - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @ParameterizedTest - @ValueSource(strings = { - "seller", - "ABCDEFGHIJKLMNOPQRSTUVWXYZ", - "0123456789", - "seller_", - "seller-" - }) - void username_속성이_올바른_형식을_따르면_204_No_Content_상태코드를_반환한다( - String username, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - username, - "password", - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(204); - } - - @Test - void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - generateUsername(), - null, - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @ParameterizedTest - @MethodSource("com.study.tdd.support.TestDataSource#invalidPasswords") - void password_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( - String password, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - generateUsername(), - password, - generateEmail() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - /** - * 이메일 유일성(uniqueness) 제약 명세. - * - *

        Arrange 단계에서 먼저 한 번 가입시켜 상태를 만든 뒤, 같은 이메일로 재요청하면 실패해야 함을 검증한다. - * 형식 검증(단일 요청)과 달리 "선행 상태"에 의존하는 계약이라는 점이 핵심이다. - * 유일성은 DB의 {@code unique} 제약으로 보장되며, 그 위반이 HTTP {@code 400}으로 매핑되는지까지 확인한다.

        - */ - @Test - void email_속성에_이미_존재하는_이메일_주소가_지정되면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - String email = generateEmail(); - - client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - email, - generateUsername(), - "password", - generateEmail() - ), - Void.class - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - email, - generateUsername(), - "password", - generateEmail() - ), - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @Test - void username_속성에_이미_존재하는_사용자이름이_지정되면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - String username = generateUsername(); - - client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - generateEmail(), - username, - "password", - generateEmail() - ), - Void.class - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - new CreateSellerCommand( - generateEmail(), - username, - "password", - generateEmail() - ), - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @Test - void 비밀번호를_올바르게_암호화한다( - @Autowired TestRestTemplate client, - @Autowired SellerRepository repository, - @Autowired PasswordEncoder encoder - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - generateUsername(), - generatePassword(), - generateEmail() - ); - - // Act - client.postForEntity("/seller/signUp", command, Void.class); - - // Assert - Seller seller = repository - .findAll() - .stream() - .filter(x -> x.getEmail().equals(command.email())) - .findFirst() - .orElseThrow(); - String actual = seller.getHashedPassword(); - assertThat(actual).isNotNull(); - assertThat(encoder.matches(command.password(), actual)).isTrue(); - } - - @ParameterizedTest - @MethodSource("com.study.tdd.support.TestDataSource#invalidEmails") - void contactEmail_속성이_올바르게_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( - String contactEmail, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateSellerCommand( - generateEmail(), - generateUsername(), - generatePassword(), - contactEmail - ); - - // Act - ResponseEntity response = client.postForEntity( - "/seller/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } + /** + * 회원가입의 해피 패스(happy path) — 모든 속성이 유효하면 + * 본문 없이 성공을 뜻하는 {@code 204 No Content}를 반환해야 한다. + * + *

        나머지 케이스들이 검증하는 "실패 계약"의 기준점이 되는 기본 명세이다.

        + */ + @Test + void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + null, + generateUsername(), + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + /** + * 이메일 형식 검증의 대표 명세. 하나의 규칙(정규식)이 막아야 할 여러 경계값을 {@link ValueSource}로 나열해 한 메서드로 검증한다. + * + *

        골뱅이 누락·도메인 누락·TLD 누락 등 "형식은 갖췄지만 무효"인 값들을 모아 검증 로직의 빈틈을 좁힌다. 케이스 추가는 문자열 한 줄로 끝난다.

        + */ + @ParameterizedTest + @ValueSource(strings = { + "invalid-email", + "invalid-email@", + "invalid-email@test", + "invalid-email@test.", + "invalid-email@.com" + }) + void email_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String email, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + email, + generateUsername(), + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + null, + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "se", + "seller ", + "seller.", + "seller!", + "seller@" + }) + void username_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "seller", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "0123456789", + "seller_", + "seller-" + }) + void username_속성이_올바른_형식을_따르면_204_No_Content_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + null, + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @InvalidPasswordSource + void password_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String password, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + password, + generateEmail() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + /** + * 이메일 유일성(uniqueness) 제약 명세. + * + *

        Arrange 단계에서 먼저 한 번 가입시켜 상태를 만든 뒤, 같은 이메일로 재요청하면 실패해야 함을 검증한다. + * 형식 검증(단일 요청)과 달리 "선행 상태"에 의존하는 계약이라는 점이 핵심이다. + * 유일성은 DB의 {@code unique} 제약으로 보장되며, 그 위반이 HTTP {@code 400}으로 매핑되는지까지 확인한다.

        + */ + @Test + void email_속성에_이미_존재하는_이메일_주소가_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + "password", + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + email, + generateUsername(), + "password", + generateEmail() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성에_이미_존재하는_사용자이름이_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String username = generateUsername(); + + client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + new CreateSellerCommand( + generateEmail(), + username, + "password", + generateEmail() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 비밀번호를_올바르게_암호화한다( + @Autowired TestRestTemplate client, + @Autowired SellerRepository repository, + @Autowired PasswordEncoder encoder + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + generatePassword(), + generateEmail() + ); + + // Act + client.postForEntity("/seller/signUp", command, Void.class); + + // Assert + Seller seller = repository + .findAll() + .stream() + .filter(x -> x.getEmail().equals(command.email())) + .findFirst() + .orElseThrow(); + String actual = seller.getHashedPassword(); + assertThat(actual).isNotNull(); + assertThat(encoder.matches(command.password(), actual)).isTrue(); + } + + @ParameterizedTest + @InvalidEmailSource + void contactEmail_속성이_올바르게_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + String contactEmail, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateSellerCommand( + generateEmail(), + generateUsername(), + generatePassword(), + contactEmail + ); + + // Act + ResponseEntity response = client.postForEntity( + "/seller/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } } diff --git a/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java b/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java index 7539de9..ee45de0 100644 --- a/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java +++ b/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java @@ -4,10 +4,11 @@ import com.study.tdd.application.command.CreateShopperCommand; import com.study.tdd.domain.Shopper; import com.study.tdd.infrastructure.persistence.ShopperRepository; +import com.study.tdd.support.InvalidPasswordSource; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.resttestclient.TestRestTemplate; @@ -35,308 +36,308 @@ @DisplayName("POST /shopper/signUp") public class POST_specs { - /** - * 회원가입의 해피 패스(happy path) — 모든 속성이 유효하면 - * 본문 없이 성공을 뜻하는 {@code 204 No Content}를 반환해야 한다. - */ - @Test - void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - generateEmail(), - generateUsername(), - generatePassword() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(204); - } - - @Test - void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - null, - generateUsername(), - generatePassword() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @ParameterizedTest - @ValueSource(strings = { - "invalid-email", - "invalid-email@", - "invalid-email@test", - "invalid-email@test.", - "invalid-email@.com" - }) - void email_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( - String email, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - email, - generateUsername(), - generatePassword() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @Test - void username_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - generateEmail(), - null, - generatePassword() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @ParameterizedTest - @ValueSource(strings = { - "", - "sh", - "shopper ", - "shopper.", - "shopper!", - "shopper@" - }) - void username_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( - String username, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - generateEmail(), - username, - generatePassword() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @ParameterizedTest - @ValueSource(strings = { - "shopper", - "ABCDEFGHIJKLMNOPQRSTUVWXYZ", - "0123456789", - "shopper_", - "shopper-" - }) - void username_속성이_올바른_형식을_따르면_204_No_Content_상태코드를_반환한다( - String username, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - generateEmail(), - username, - generatePassword() - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(204); - } - - @Test - void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - generateEmail(), - generateUsername(), - null - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @ParameterizedTest - @MethodSource("com.study.tdd.support.TestDataSource#invalidPasswords") - void password_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( - String password, - @Autowired TestRestTemplate client - ) { - // Arrange - var command = new CreateShopperCommand( - generateEmail(), - generateUsername(), - password - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - command, - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - /** - * 이메일 유일성(uniqueness) 제약 명세. - * - *

        Arrange 단계에서 먼저 한 번 가입시켜 상태를 만든 뒤, - * 같은 이메일로 재요청하면 실패해야 함을 검증한다.

        - */ - @Test - void email_속성에_이미_존재하는_이메일_주소가_지정되면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - String email = generateEmail(); - - client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - email, - generateUsername(), - generatePassword() - ), - Void.class - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - email, - generateUsername(), - generatePassword() - ), - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @Test - void username_속성에_이미_존재하는_사용자_이름이_지정되면_400_Bad_Request_상태코드를_반환한다( - @Autowired TestRestTemplate client - ) { - // Arrange - String username = generateUsername(); - - client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - generateEmail(), - username, - generatePassword() - ), - Void.class - ); - - // Act - ResponseEntity response = client.postForEntity( - "/shopper/signUp", - new CreateShopperCommand( - generateEmail(), - username, - generatePassword() - ), - Void.class - ); - - // Assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @Test - void 비밀번호를_올바르게_암호화한다( - @Autowired TestRestTemplate client, - @Autowired ShopperRepository repository, - @Autowired PasswordEncoder encoder - ) { - // Arrange - var command = new CreateShopperCommand( - generateEmail(), - generateUsername(), - generatePassword() - ); - - // Act - client.postForEntity("/shopper/signUp", command, Void.class); - - // Assert - Shopper shopper = repository - .findAll() - .stream() - .filter(x -> x.getEmail().equals(command.email())) - .findFirst() - .orElseThrow(); - String actual = shopper.getHashedPassword(); - assertThat(actual).isNotNull(); - assertThat(encoder.matches(command.password(), actual)).isTrue(); - } + /** + * 회원가입의 해피 패스(happy path) — 모든 속성이 유효하면 + * 본문 없이 성공을 뜻하는 {@code 204 No Content}를 반환해야 한다. + */ + @Test + void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + null, + generateUsername(), + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-email", + "invalid-email@", + "invalid-email@test", + "invalid-email@test.", + "invalid-email@.com" + }) + void email_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String email, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + email, + generateUsername(), + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + null, + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "sh", + "shopper ", + "shopper.", + "shopper!", + "shopper@" + }) + void username_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @ValueSource(strings = { + "shopper", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "0123456789", + "shopper_", + "shopper-" + }) + void username_속성이_올바른_형식을_따르면_204_No_Content_상태코드를_반환한다( + String username, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @Test + void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + null + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @ParameterizedTest + @InvalidPasswordSource + void password_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String password, + @Autowired TestRestTemplate client + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + password + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + /** + * 이메일 유일성(uniqueness) 제약 명세. + * + *

        Arrange 단계에서 먼저 한 번 가입시켜 상태를 만든 뒤, + * 같은 이메일로 재요청하면 실패해야 함을 검증한다.

        + */ + @Test + void email_속성에_이미_존재하는_이메일_주소가_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + generatePassword() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + generatePassword() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void username_속성에_이미_존재하는_사용자_이름이_지정되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String username = generateUsername(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + generateEmail(), + username, + generatePassword() + ), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 비밀번호를_올바르게_암호화한다( + @Autowired TestRestTemplate client, + @Autowired ShopperRepository repository, + @Autowired PasswordEncoder encoder + ) { + // Arrange + var command = new CreateShopperCommand( + generateEmail(), + generateUsername(), + generatePassword() + ); + + // Act + client.postForEntity("/shopper/signUp", command, Void.class); + + // Assert + Shopper shopper = repository + .findAll() + .stream() + .filter(x -> x.getEmail().equals(command.email())) + .findFirst() + .orElseThrow(); + String actual = shopper.getHashedPassword(); + assertThat(actual).isNotNull(); + assertThat(encoder.matches(command.password(), actual)).isTrue(); + } } diff --git a/src/test/java/com/study/tdd/support/InvalidEmailSource.java b/src/test/java/com/study/tdd/support/InvalidEmailSource.java new file mode 100644 index 0000000..26d79b3 --- /dev/null +++ b/src/test/java/com/study/tdd/support/InvalidEmailSource.java @@ -0,0 +1,21 @@ +package com.study.tdd.support; + +import java.lang.annotation.Retention; + +import org.junit.jupiter.params.provider.MethodSource; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 무효 이메일 반례 묶음을 주입하는 선언적 파라미터 소스. + * + *

        {@code @MethodSource("com.study.tdd.support.TestDataSource#invalidEmails")}의 긴 문자열 + * 경로를 애노테이션 이름 하나로 감싼다. 스펙에서는 {@code @ParameterizedTest} 옆에 + * {@code @InvalidEmailSource} 한 줄만 붙이면 되며, 오타에 취약한 경로 문자열이 사라지고 + * 검증 의도가 이름으로 드러난다. 정책의 반례가 한곳({@link TestDataSource})에 모여 있어 + * 이메일 정책이 바뀌면 반례 목록도 한곳만 고치면 된다.

        + */ +@Retention(RUNTIME) +@MethodSource("com.study.tdd.support.TestDataSource#invalidEmails") +public @interface InvalidEmailSource { +} diff --git a/src/test/java/com/study/tdd/support/InvalidPasswordSource.java b/src/test/java/com/study/tdd/support/InvalidPasswordSource.java new file mode 100644 index 0000000..3497527 --- /dev/null +++ b/src/test/java/com/study/tdd/support/InvalidPasswordSource.java @@ -0,0 +1,21 @@ +package com.study.tdd.support; + +import java.lang.annotation.Retention; + +import org.junit.jupiter.params.provider.MethodSource; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 무효 비밀번호 반례 묶음을 주입하는 선언적 파라미터 소스. + * + *

        {@code @MethodSource("com.study.tdd.support.TestDataSource#invalidPasswords")}의 긴 문자열 + * 경로를 애노테이션 이름 하나로 감싼다. 스펙에서는 {@code @ParameterizedTest} 옆에 + * {@code @InvalidPasswordSource} 한 줄만 붙이면 되며, 오타에 취약한 경로 문자열이 사라지고 + * 검증 의도가 이름으로 드러난다. 비밀번호 정책의 반례가 한곳({@link TestDataSource})에 모여 + * 있어, 그 정책을 검증하는 모든 스펙(판매자·구매자 가입 등)이 같은 목록을 공유한다.

        + */ +@Retention(RUNTIME) +@MethodSource("com.study.tdd.support.TestDataSource#invalidPasswords") +public @interface InvalidPasswordSource { +} From 2fdf4fc0eb17fd295d54d74a925097899a995db8 Mon Sep 17 00:00:00 2001 From: woo jin Date: Tue, 14 Jul 2026 21:49:16 +0900 Subject: [PATCH 20/31] docs: add seller product register and query API test scenarios --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2895601..d20a939 100644 --- a/README.md +++ b/README.md @@ -60,4 +60,21 @@ - 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다. - 서로 다른 구매자의 식별자는 서로 다르다. - 같은 구매자의 식별자는 항상 같아야 한다. -- 구매자의 기본 정보가 올바르게 설정된다. \ No newline at end of file +- 구매자의 기본 정보가 올바르게 설정된다. + +# 판매자 상품 등록 API 테스트 시나리오 목록 + +- 올바르게 요청하면 201 Created 상태코드를 반환한다. +- 판매자가 아닌 사용자의 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다. +- imageUri 속성이 URI 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다. +- 올바르게 요청하면 등록된 상품 정보에 접근하는 Location 헤더를 반환한다. + +# 판매자 상품 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 판매자가 아닌 사용자의 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다. +- 존재하지 않는 식별자를 사용하면 404 Not Found 상태코드를 반환한다. +- 다른 판매자가 등록한 상품 식별자를 사용하면 404 Not Found 상태코드를 반환한다. +- 상품 식별자를 올바르게 반환한다. +- 상품 정보를 올바르게 반환한다. +- 상품 등록 시각을 올바르게 반환한다. \ No newline at end of file From 48fafe85ee91339c18cdbddf4607cd29bba4d768 Mon Sep 17 00:00:00 2001 From: woo jin Date: Tue, 14 Jul 2026 21:55:28 +0900 Subject: [PATCH 21/31] feat: implement seller product registration and lookup API --- .../controller/SellerProductController.java | 51 +++++ .../SellerRegisterProductController.java | 40 ++++ .../request/RegisterProductRequest.java | 24 +++ .../response/SellerProductView.java | 16 ++ .../SellerProductQueryService.java | 27 +++ .../SellerProductRegistrationService.java | 54 ++++++ .../command/RegisterProductCommand.java | 12 ++ .../exception/ProductErrorCode.java | 24 +++ .../application/query/FindSellerProduct.java | 6 + .../java/com/study/tdd/domain/Product.java | 43 +++++ .../validation/ProductPropertyValidator.java | 18 ++ .../config/SecurityConfiguration.java | 1 + .../persistence/ProductRepository.java | 13 ++ .../java/com/study/tdd/api/TestFixture.java | 35 ++++ .../tdd/api/seller/products/POST_specs.java | 134 +++++++++++++ .../tdd/api/seller/products/id/GET_specs.java | 177 ++++++++++++++++++ .../study/tdd/support/ProductAssertions.java | 30 +++ .../RegisterProductCommandGenerator.java | 54 ++++++ 18 files changed, 759 insertions(+) create mode 100644 src/main/java/com/study/tdd/api/controller/SellerProductController.java create mode 100644 src/main/java/com/study/tdd/api/controller/SellerRegisterProductController.java create mode 100644 src/main/java/com/study/tdd/api/controller/request/RegisterProductRequest.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/SellerProductView.java create mode 100644 src/main/java/com/study/tdd/application/SellerProductQueryService.java create mode 100644 src/main/java/com/study/tdd/application/SellerProductRegistrationService.java create mode 100644 src/main/java/com/study/tdd/application/command/RegisterProductCommand.java create mode 100644 src/main/java/com/study/tdd/application/exception/ProductErrorCode.java create mode 100644 src/main/java/com/study/tdd/application/query/FindSellerProduct.java create mode 100644 src/main/java/com/study/tdd/domain/Product.java create mode 100644 src/main/java/com/study/tdd/domain/validation/ProductPropertyValidator.java create mode 100644 src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java create mode 100644 src/test/java/com/study/tdd/api/seller/products/POST_specs.java create mode 100644 src/test/java/com/study/tdd/api/seller/products/id/GET_specs.java create mode 100644 src/test/java/com/study/tdd/support/ProductAssertions.java create mode 100644 src/test/java/com/study/tdd/support/RegisterProductCommandGenerator.java diff --git a/src/main/java/com/study/tdd/api/controller/SellerProductController.java b/src/main/java/com/study/tdd/api/controller/SellerProductController.java new file mode 100644 index 0000000..ee23fe6 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerProductController.java @@ -0,0 +1,51 @@ +package com.study.tdd.api.controller; + +import java.security.Principal; +import java.util.UUID; + +import com.study.tdd.api.controller.response.SellerProductView; +import com.study.tdd.application.SellerProductQueryService; +import com.study.tdd.application.query.FindSellerProduct; +import com.study.tdd.domain.Product; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SellerProductController { + + private final SellerProductQueryService sellerProductQueryService; + + @Autowired + public SellerProductController(SellerProductQueryService sellerProductQueryService) { + this.sellerProductQueryService = sellerProductQueryService; + } + + @GetMapping("/seller/products/{id}") + ResponseEntity findProduct( + @PathVariable UUID id, + Principal user + ) { + UUID sellerId = UUID.fromString(user.getName()); + return ResponseEntity.of( + sellerProductQueryService + .findProduct(new FindSellerProduct(sellerId, id)) + .map(SellerProductController::toView) + ); + } + + private static SellerProductView toView(Product product) { + return new SellerProductView( + product.getId(), + product.getName(), + product.getImageUri(), + product.getDescription(), + product.getPriceAmount(), + product.getStockQuantity(), + product.getRegisteredTimeUtc() + ); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/SellerRegisterProductController.java b/src/main/java/com/study/tdd/api/controller/SellerRegisterProductController.java new file mode 100644 index 0000000..638a2f6 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerRegisterProductController.java @@ -0,0 +1,40 @@ +package com.study.tdd.api.controller; + +import java.net.URI; +import java.security.Principal; +import java.util.UUID; + +import com.study.tdd.api.controller.request.RegisterProductRequest; +import com.study.tdd.application.SellerProductRegistrationService; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SellerRegisterProductController { + + private final SellerProductRegistrationService sellerProductRegistrationService; + + @Autowired + public SellerRegisterProductController( + SellerProductRegistrationService sellerProductRegistrationService + ) { + this.sellerProductRegistrationService = sellerProductRegistrationService; + } + + @PostMapping("/seller/products") + ResponseEntity registerProduct( + @RequestBody RegisterProductRequest request, + Principal user + ) { + UUID sellerId = UUID.fromString(user.getName()); + UUID id = sellerProductRegistrationService.registerProduct( + sellerId, + request.toCommand() + ); + return ResponseEntity.created(URI.create("/seller/products/" + id)).build(); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/request/RegisterProductRequest.java b/src/main/java/com/study/tdd/api/controller/request/RegisterProductRequest.java new file mode 100644 index 0000000..f5dc307 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/request/RegisterProductRequest.java @@ -0,0 +1,24 @@ +package com.study.tdd.api.controller.request; + +import java.math.BigDecimal; + +import com.study.tdd.application.command.RegisterProductCommand; + +public record RegisterProductRequest( + String name, + String imageUri, + String description, + BigDecimal priceAmount, + int stockQuantity +) { + + public RegisterProductCommand toCommand() { + return new RegisterProductCommand( + name, + imageUri, + description, + priceAmount, + stockQuantity + ); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java b/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java new file mode 100644 index 0000000..b642b15 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java @@ -0,0 +1,16 @@ +package com.study.tdd.api.controller.response; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +public record SellerProductView( + UUID id, + String name, + String imageUri, + String description, + BigDecimal priceAmount, + int stockQuantity, + LocalDateTime registeredTimeUtc +) { +} diff --git a/src/main/java/com/study/tdd/application/SellerProductQueryService.java b/src/main/java/com/study/tdd/application/SellerProductQueryService.java new file mode 100644 index 0000000..2472219 --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerProductQueryService.java @@ -0,0 +1,27 @@ +package com.study.tdd.application; + +import java.util.Optional; + +import com.study.tdd.application.query.FindSellerProduct; +import com.study.tdd.domain.Product; +import com.study.tdd.infrastructure.persistence.ProductRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class SellerProductQueryService { + + private final ProductRepository repository; + + @Autowired + public SellerProductQueryService(ProductRepository repository) { + this.repository = repository; + } + + public Optional findProduct(FindSellerProduct query) { + return repository + .findById(query.productId()) + .filter(product -> product.getSellerId().equals(query.sellerId())); + } +} diff --git a/src/main/java/com/study/tdd/application/SellerProductRegistrationService.java b/src/main/java/com/study/tdd/application/SellerProductRegistrationService.java new file mode 100644 index 0000000..3edc038 --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerProductRegistrationService.java @@ -0,0 +1,54 @@ +package com.study.tdd.application; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.study.tdd.application.command.RegisterProductCommand; +import com.study.tdd.application.exception.BusinessException; +import com.study.tdd.application.exception.ProductErrorCode; +import com.study.tdd.domain.Product; +import com.study.tdd.infrastructure.persistence.ProductRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import static java.time.ZoneOffset.UTC; +import static com.study.tdd.domain.validation.ProductPropertyValidator.isImageUriValid; + +@Service +public class SellerProductRegistrationService { + + private final ProductRepository repository; + + @Autowired + public SellerProductRegistrationService(ProductRepository repository) { + this.repository = repository; + } + + public UUID registerProduct(UUID sellerId, RegisterProductCommand command) { + if (!isImageUriValid(command.imageUri())) { + throw new BusinessException(ProductErrorCode.INVALID_IMAGE_URI); + } + + UUID id = UUID.randomUUID(); + repository.save(createProduct(id, sellerId, command)); + return id; + } + + private static Product createProduct( + UUID id, + UUID sellerId, + RegisterProductCommand command + ) { + var product = new Product(); + product.setId(id); + product.setSellerId(sellerId); + product.setName(command.name()); + product.setImageUri(command.imageUri()); + product.setDescription(command.description()); + product.setPriceAmount(command.priceAmount()); + product.setStockQuantity(command.stockQuantity()); + product.setRegisteredTimeUtc(LocalDateTime.now(UTC)); + return product; + } +} diff --git a/src/main/java/com/study/tdd/application/command/RegisterProductCommand.java b/src/main/java/com/study/tdd/application/command/RegisterProductCommand.java new file mode 100644 index 0000000..5af5bb0 --- /dev/null +++ b/src/main/java/com/study/tdd/application/command/RegisterProductCommand.java @@ -0,0 +1,12 @@ +package com.study.tdd.application.command; + +import java.math.BigDecimal; + +public record RegisterProductCommand( + String name, + String imageUri, + String description, + BigDecimal priceAmount, + int stockQuantity +) { +} diff --git a/src/main/java/com/study/tdd/application/exception/ProductErrorCode.java b/src/main/java/com/study/tdd/application/exception/ProductErrorCode.java new file mode 100644 index 0000000..68c980b --- /dev/null +++ b/src/main/java/com/study/tdd/application/exception/ProductErrorCode.java @@ -0,0 +1,24 @@ +package com.study.tdd.application.exception; + +public enum ProductErrorCode implements ErrorCode { + + INVALID_IMAGE_URI("PRODUCT_001", "상품 이미지 주소가 올바르지 않습니다."); + + private final String code; + private final String message; + + ProductErrorCode(String code, String message) { + this.code = code; + this.message = message; + } + + @Override + public String code() { + return code; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/study/tdd/application/query/FindSellerProduct.java b/src/main/java/com/study/tdd/application/query/FindSellerProduct.java new file mode 100644 index 0000000..53e5a24 --- /dev/null +++ b/src/main/java/com/study/tdd/application/query/FindSellerProduct.java @@ -0,0 +1,6 @@ +package com.study.tdd.application.query; + +import java.util.UUID; + +public record FindSellerProduct(UUID sellerId, UUID productId) { +} diff --git a/src/main/java/com/study/tdd/domain/Product.java b/src/main/java/com/study/tdd/domain/Product.java new file mode 100644 index 0000000..95b6abb --- /dev/null +++ b/src/main/java/com/study/tdd/domain/Product.java @@ -0,0 +1,43 @@ +package com.study.tdd.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +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.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Table(indexes = @Index(columnList = "sellerId")) +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long dataKey; + + @Column(unique = true) + private UUID id; + + private UUID sellerId; + + private String name; + + private String imageUri; + + private String description; + + private BigDecimal priceAmount; + + private Integer stockQuantity; + + private LocalDateTime registeredTimeUtc; +} diff --git a/src/main/java/com/study/tdd/domain/validation/ProductPropertyValidator.java b/src/main/java/com/study/tdd/domain/validation/ProductPropertyValidator.java new file mode 100644 index 0000000..b3aac0d --- /dev/null +++ b/src/main/java/com/study/tdd/domain/validation/ProductPropertyValidator.java @@ -0,0 +1,18 @@ +package com.study.tdd.domain.validation; + +import java.net.URI; + +public class ProductPropertyValidator { + + public static boolean isImageUriValid(String imageUri) { + if (imageUri == null) { + return false; + } + + try { + return URI.create(imageUri).getHost() != null; + } catch (IllegalArgumentException exception) { + return false; + } + } +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index 2c275aa..43a7624 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -23,6 +23,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder .requestMatchers("/shopper/signUp").permitAll() .requestMatchers("/shopper/issueToken").permitAll() .requestMatchers("/seller/me").access(hasScope("seller")) + .requestMatchers("/seller/products", "/seller/products/**").access(hasScope("seller")) .requestMatchers("/shopper/me").access(hasScope("shopper")) .anyRequest().authenticated() ) diff --git a/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java b/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java new file mode 100644 index 0000000..c48b7ed --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java @@ -0,0 +1,13 @@ +package com.study.tdd.infrastructure.persistence; + +import java.util.Optional; +import java.util.UUID; + +import com.study.tdd.domain.Product; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { + + Optional findById(UUID id); +} diff --git a/src/test/java/com/study/tdd/api/TestFixture.java b/src/test/java/com/study/tdd/api/TestFixture.java index b3b46f6..4ca32bf 100644 --- a/src/test/java/com/study/tdd/api/TestFixture.java +++ b/src/test/java/com/study/tdd/api/TestFixture.java @@ -1,10 +1,14 @@ package com.study.tdd.api; +import java.net.URI; +import java.util.UUID; + import com.study.tdd.api.controller.response.AccessTokenCarrier; import com.study.tdd.api.controller.response.SellerMeView; import com.study.tdd.api.controller.response.ShopperMeView; import com.study.tdd.application.command.CreateSellerCommand; import com.study.tdd.application.command.CreateShopperCommand; +import com.study.tdd.application.command.RegisterProductCommand; import com.study.tdd.application.query.IssueSellerToken; import com.study.tdd.application.query.IssueShopperToken; @@ -17,6 +21,7 @@ import static java.util.Objects.requireNonNull; import static com.study.tdd.support.EmailGenerator.generateEmail; import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommand; import static com.study.tdd.support.UsernameGenerator.generateUsername; /** @@ -91,6 +96,36 @@ public String createSellerThenIssueToken() { return issueSellerToken(email, password); } + public void createSellerThenSetAsDefaultUser() { + String email = generateEmail(); + String password = generatePassword(); + createSeller(email, generateUsername(), password, generateEmail()); + setSellerAsDefaultUser(email, password); + } + + public void createShopperThenSetAsDefaultUser() { + String email = generateEmail(); + String password = generatePassword(); + createShopper(email, generateUsername(), password); + setShopperAsDefaultUser(email, password); + } + + public UUID registerProduct() { + return registerProduct(generateRegisterProductCommand()); + } + + public UUID registerProduct(RegisterProductCommand command) { + ResponseEntity response = client.postForEntity( + "/seller/products", + command, + Void.class + ); + URI location = requireNonNull(response.getHeaders().getLocation()); + String path = location.getPath(); + String id = path.substring("/seller/products/".length()); + return UUID.fromString(id); + } + public void setShopperAsDefaultUser(String email, String password) { setDefaultAuthorization("Bearer " + issueShopperToken(email, password)); } diff --git a/src/test/java/com/study/tdd/api/seller/products/POST_specs.java b/src/test/java/com/study/tdd/api/seller/products/POST_specs.java new file mode 100644 index 0000000..5d965c2 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/products/POST_specs.java @@ -0,0 +1,134 @@ +package com.study.tdd.api.seller.products; + +import java.net.URI; +import java.util.UUID; +import java.util.function.Predicate; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommand; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommandWithImageUri; + +/** + * {@code POST /seller/products} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

        인증된 판매자가 상품을 등록하는 계약을 다룬다. 등록에 성공하면 201 Created와 함께 + * 등록된 상품 자원을 가리키는 {@code Location} 헤더를 반환한다.

        + * + *

        테스트 시나리오

        + *
          + *
        • 올바르게 요청하면 201 Created 상태코드를 반환한다.
        • + *
        • 판매자가 아닌 사용자의 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다.
        • + *
        • imageUri 속성이 URI 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다.
        • + *
        • 올바르게 요청하면 등록된 상품 정보에 접근하는 Location 헤더를 반환한다.
        • + *
        + */ +@TddApiTest +@DisplayName("POST /seller/products") +public class POST_specs { + + @Test + void 올바르게_요청하면_201_Created_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().postForEntity( + "/seller/products", + generateRegisterProductCommand(), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(201); + } + + @Test + void 판매자가_아닌_사용자의_접근_토큰을_사용하면_403_Forbidden_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().postForEntity( + "/seller/products", + generateRegisterProductCommand(), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-uri", + "http://", + "://missing.scheme.com" + }) + void imageUri_속성이_URI_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다( + String imageUri, + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().postForEntity( + "/seller/products", + generateRegisterProductCommandWithImageUri(imageUri), + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 올바르게_요청하면_등록된_상품_정보에_접근하는_Location_헤더를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().postForEntity( + "/seller/products", + generateRegisterProductCommand(), + Void.class + ); + + // Assert + URI actual = response.getHeaders().getLocation(); + assertThat(actual).isNotNull(); + assertThat(actual.isAbsolute()).isFalse(); + assertThat(actual.getPath()) + .startsWith("/seller/products/") + .matches(endsWithUUID()); + } + + private Predicate endsWithUUID() { + return path -> { + String[] segments = path.split("/"); + String lastSegment = segments[segments.length - 1]; + try { + UUID.fromString(lastSegment); + return true; + } catch (IllegalArgumentException exception) { + return false; + } + }; + } +} diff --git a/src/test/java/com/study/tdd/api/seller/products/id/GET_specs.java b/src/test/java/com/study/tdd/api/seller/products/id/GET_specs.java new file mode 100644 index 0000000..5baf293 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/products/id/GET_specs.java @@ -0,0 +1,177 @@ +package com.study.tdd.api.seller.products.id; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; +import com.study.tdd.api.controller.response.SellerProductView; +import com.study.tdd.application.command.RegisterProductCommand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; + +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static com.study.tdd.support.ProductAssertions.isDerivedFrom; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommand; + +/** + * {@code GET /seller/products/{id}} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

        인증된 판매자가 자신이 등록한 특정 상품을 조회하는 계약을 다룬다. 다른 판매자의 상품이나 + * 존재하지 않는 식별자는 404 Not Found로 응답한다.

        + * + *

        테스트 시나리오

        + *
          + *
        • 올바르게 요청하면 200 OK 상태코드를 반환한다.
        • + *
        • 판매자가 아닌 사용자의 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다.
        • + *
        • 존재하지 않는 식별자를 사용하면 404 Not Found 상태코드를 반환한다.
        • + *
        • 다른 판매자가 등록한 상품 식별자를 사용하면 404 Not Found 상태코드를 반환한다.
        • + *
        • 상품 식별자를 올바르게 반환한다.
        • + *
        • 상품 정보를 올바르게 반환한다.
        • + *
        • 상품 등록 시각을 올바르게 반환한다.
        • + *
        + */ +@TddApiTest +@DisplayName("GET /seller/products/{id}") +public class GET_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 판매자가_아닌_사용자의_접근_토큰을_사용하면_403_Forbidden_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + void 존재하지_않는_식별자를_사용하면_404_Not_Found_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = UUID.randomUUID(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + @Test + void 다른_판매자가_등록한_상품_식별자를_사용하면_404_Not_Found_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + fixture.createSellerThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + @Test + void 상품_식별자를_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + // Act + SellerProductView actual = fixture.client().getForObject( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(actual).isNotNull(); + assertThat(actual.id()).isEqualTo(id); + } + + @Test + void 상품_정보를_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + RegisterProductCommand command = generateRegisterProductCommand(); + UUID id = fixture.registerProduct(command); + + // Act + SellerProductView actual = fixture.client().getForObject( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(actual).satisfies(isDerivedFrom(command)); + } + + @Test + void 상품_등록_시각을_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + LocalDateTime referenceTime = LocalDateTime.now(UTC); + UUID id = fixture.registerProduct(); + + // Act + SellerProductView actual = fixture.client().getForObject( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(actual.registeredTimeUtc()) + .isCloseTo(referenceTime, within(1, SECONDS)); + } +} diff --git a/src/test/java/com/study/tdd/support/ProductAssertions.java b/src/test/java/com/study/tdd/support/ProductAssertions.java new file mode 100644 index 0000000..50ad4cf --- /dev/null +++ b/src/test/java/com/study/tdd/support/ProductAssertions.java @@ -0,0 +1,30 @@ +package com.study.tdd.support; + +import java.math.BigDecimal; +import java.util.function.Predicate; + +import com.study.tdd.api.controller.response.SellerProductView; +import com.study.tdd.application.command.RegisterProductCommand; + +import org.assertj.core.api.ThrowingConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ProductAssertions { + + public static ThrowingConsumer isDerivedFrom( + RegisterProductCommand command + ) { + return product -> { + assertThat(product.name()).isEqualTo(command.name()); + assertThat(product.imageUri()).isEqualTo(command.imageUri()); + assertThat(product.description()).isEqualTo(command.description()); + assertThat(product.priceAmount()).matches(equalTo(command.priceAmount())); + assertThat(product.stockQuantity()).isEqualTo(command.stockQuantity()); + }; + } + + private static Predicate equalTo(BigDecimal expected) { + return actual -> actual.compareTo(expected) == 0; + } +} diff --git a/src/test/java/com/study/tdd/support/RegisterProductCommandGenerator.java b/src/test/java/com/study/tdd/support/RegisterProductCommandGenerator.java new file mode 100644 index 0000000..1a49f44 --- /dev/null +++ b/src/test/java/com/study/tdd/support/RegisterProductCommandGenerator.java @@ -0,0 +1,54 @@ +package com.study.tdd.support; + +import java.math.BigDecimal; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import com.study.tdd.application.command.RegisterProductCommand; + +public class RegisterProductCommandGenerator { + + public static RegisterProductCommand generateRegisterProductCommand() { + return new RegisterProductCommand( + generateProductName(), + generateProductImageUri(), + generateProductDescription(), + generateProductPriceAmount(), + generateProductStockQuantity() + ); + } + + public static RegisterProductCommand generateRegisterProductCommandWithImageUri( + String imageUri + ) { + return new RegisterProductCommand( + generateProductName(), + imageUri, + generateProductDescription(), + generateProductPriceAmount(), + generateProductStockQuantity() + ); + } + + private static String generateProductName() { + return "name" + UUID.randomUUID(); + } + + private static String generateProductImageUri() { + return "https://test.com/images/" + UUID.randomUUID(); + } + + private static String generateProductDescription() { + return "description" + UUID.randomUUID(); + } + + private static BigDecimal generateProductPriceAmount() { + ThreadLocalRandom random = ThreadLocalRandom.current(); + return BigDecimal.valueOf(random.nextInt(10000, 100000)); + } + + private static int generateProductStockQuantity() { + ThreadLocalRandom random = ThreadLocalRandom.current(); + return random.nextInt(10, 100); + } +} From ebe9c4d0152b2fa673d56b15c462b2c337370f4b Mon Sep 17 00:00:00 2001 From: woo jin Date: Tue, 14 Jul 2026 23:18:05 +0900 Subject: [PATCH 22/31] docs: add seller product list query API test scenarios --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d20a939..7873f40 100644 --- a/README.md +++ b/README.md @@ -77,4 +77,13 @@ - 다른 판매자가 등록한 상품 식별자를 사용하면 404 Not Found 상태코드를 반환한다. - 상품 식별자를 올바르게 반환한다. - 상품 정보를 올바르게 반환한다. -- 상품 등록 시각을 올바르게 반환한다. \ No newline at end of file +- 상품 등록 시각을 올바르게 반환한다. + +# 판매자 상품 목록 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 판매자가 등록한 모든 상품을 반환한다. +- 다른 판매자가 등록한 상품이 포함되지 않는다. +- 상품 정보를 올바르게 반환한다. +- 상품 등록 시각을 올바르게 반환한다. +- 상품 목록을 등록 시점 역순으로 정렬한다. \ No newline at end of file From 2b1e350195da06e1fba8662d6fc72642ff514a1d Mon Sep 17 00:00:00 2001 From: woo jin Date: Tue, 14 Jul 2026 23:18:16 +0900 Subject: [PATCH 23/31] feat: implement seller product list query API --- .../controller/SellerProductController.java | 15 +- .../controller/SellerProductsController.java | 35 ++++ .../api/controller/response/ArrayCarrier.java | 4 + .../response/SellerProductView.java | 14 ++ .../SellerProductQueryService.java | 13 ++ .../application/query/GetSellerProducts.java | 6 + .../persistence/ProductRepository.java | 3 + .../java/com/study/tdd/api/TestFixture.java | 5 + .../tdd/api/seller/products/GET_specs.java | 177 ++++++++++++++++++ 9 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/study/tdd/api/controller/SellerProductsController.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/ArrayCarrier.java create mode 100644 src/main/java/com/study/tdd/application/query/GetSellerProducts.java create mode 100644 src/test/java/com/study/tdd/api/seller/products/GET_specs.java diff --git a/src/main/java/com/study/tdd/api/controller/SellerProductController.java b/src/main/java/com/study/tdd/api/controller/SellerProductController.java index ee23fe6..13d6b08 100644 --- a/src/main/java/com/study/tdd/api/controller/SellerProductController.java +++ b/src/main/java/com/study/tdd/api/controller/SellerProductController.java @@ -6,7 +6,6 @@ import com.study.tdd.api.controller.response.SellerProductView; import com.study.tdd.application.SellerProductQueryService; import com.study.tdd.application.query.FindSellerProduct; -import com.study.tdd.domain.Product; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -33,19 +32,7 @@ ResponseEntity findProduct( return ResponseEntity.of( sellerProductQueryService .findProduct(new FindSellerProduct(sellerId, id)) - .map(SellerProductController::toView) - ); - } - - private static SellerProductView toView(Product product) { - return new SellerProductView( - product.getId(), - product.getName(), - product.getImageUri(), - product.getDescription(), - product.getPriceAmount(), - product.getStockQuantity(), - product.getRegisteredTimeUtc() + .map(SellerProductView::from) ); } } diff --git a/src/main/java/com/study/tdd/api/controller/SellerProductsController.java b/src/main/java/com/study/tdd/api/controller/SellerProductsController.java new file mode 100644 index 0000000..fc874b6 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerProductsController.java @@ -0,0 +1,35 @@ +package com.study.tdd.api.controller; + +import java.security.Principal; +import java.util.UUID; + +import com.study.tdd.api.controller.response.ArrayCarrier; +import com.study.tdd.api.controller.response.SellerProductView; +import com.study.tdd.application.SellerProductQueryService; +import com.study.tdd.application.query.GetSellerProducts; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SellerProductsController { + + private final SellerProductQueryService sellerProductQueryService; + + @Autowired + public SellerProductsController(SellerProductQueryService sellerProductQueryService) { + this.sellerProductQueryService = sellerProductQueryService; + } + + @GetMapping("/seller/products") + ArrayCarrier getProducts(Principal user) { + UUID sellerId = UUID.fromString(user.getName()); + SellerProductView[] items = sellerProductQueryService + .getProducts(new GetSellerProducts(sellerId)) + .stream() + .map(SellerProductView::from) + .toArray(SellerProductView[]::new); + return new ArrayCarrier<>(items); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/response/ArrayCarrier.java b/src/main/java/com/study/tdd/api/controller/response/ArrayCarrier.java new file mode 100644 index 0000000..93ba9c5 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/ArrayCarrier.java @@ -0,0 +1,4 @@ +package com.study.tdd.api.controller.response; + +public record ArrayCarrier(T[] items) { +} diff --git a/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java b/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java index b642b15..af5e196 100644 --- a/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java +++ b/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java @@ -4,6 +4,8 @@ import java.time.LocalDateTime; import java.util.UUID; +import com.study.tdd.domain.Product; + public record SellerProductView( UUID id, String name, @@ -13,4 +15,16 @@ public record SellerProductView( int stockQuantity, LocalDateTime registeredTimeUtc ) { + + public static SellerProductView from(Product product) { + return new SellerProductView( + product.getId(), + product.getName(), + product.getImageUri(), + product.getDescription(), + product.getPriceAmount(), + product.getStockQuantity(), + product.getRegisteredTimeUtc() + ); + } } diff --git a/src/main/java/com/study/tdd/application/SellerProductQueryService.java b/src/main/java/com/study/tdd/application/SellerProductQueryService.java index 2472219..d2aad19 100644 --- a/src/main/java/com/study/tdd/application/SellerProductQueryService.java +++ b/src/main/java/com/study/tdd/application/SellerProductQueryService.java @@ -1,14 +1,19 @@ package com.study.tdd.application; +import java.util.List; import java.util.Optional; import com.study.tdd.application.query.FindSellerProduct; +import com.study.tdd.application.query.GetSellerProducts; import com.study.tdd.domain.Product; import com.study.tdd.infrastructure.persistence.ProductRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import static java.util.Comparator.comparing; +import static java.util.Comparator.reverseOrder; + @Service public class SellerProductQueryService { @@ -24,4 +29,12 @@ public Optional findProduct(FindSellerProduct query) { .findById(query.productId()) .filter(product -> product.getSellerId().equals(query.sellerId())); } + + public List getProducts(GetSellerProducts query) { + return repository + .findBySellerId(query.sellerId()) + .stream() + .sorted(comparing(Product::getRegisteredTimeUtc, reverseOrder())) + .toList(); + } } diff --git a/src/main/java/com/study/tdd/application/query/GetSellerProducts.java b/src/main/java/com/study/tdd/application/query/GetSellerProducts.java new file mode 100644 index 0000000..46aa38e --- /dev/null +++ b/src/main/java/com/study/tdd/application/query/GetSellerProducts.java @@ -0,0 +1,6 @@ +package com.study.tdd.application.query; + +import java.util.UUID; + +public record GetSellerProducts(UUID sellerId) { +} diff --git a/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java b/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java index c48b7ed..c889275 100644 --- a/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java +++ b/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java @@ -1,5 +1,6 @@ package com.study.tdd.infrastructure.persistence; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -10,4 +11,6 @@ public interface ProductRepository extends JpaRepository { Optional findById(UUID id); + + List findBySellerId(UUID sellerId); } diff --git a/src/test/java/com/study/tdd/api/TestFixture.java b/src/test/java/com/study/tdd/api/TestFixture.java index 4ca32bf..bdfa1fe 100644 --- a/src/test/java/com/study/tdd/api/TestFixture.java +++ b/src/test/java/com/study/tdd/api/TestFixture.java @@ -1,6 +1,7 @@ package com.study.tdd.api; import java.net.URI; +import java.util.List; import java.util.UUID; import com.study.tdd.api.controller.response.AccessTokenCarrier; @@ -126,6 +127,10 @@ public UUID registerProduct(RegisterProductCommand command) { return UUID.fromString(id); } + public List registerProducts() { + return List.of(registerProduct(), registerProduct(), registerProduct()); + } + public void setShopperAsDefaultUser(String email, String password) { setDefaultAuthorization("Bearer " + issueShopperToken(email, password)); } diff --git a/src/test/java/com/study/tdd/api/seller/products/GET_specs.java b/src/test/java/com/study/tdd/api/seller/products/GET_specs.java new file mode 100644 index 0000000..1a42ac0 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/products/GET_specs.java @@ -0,0 +1,177 @@ +package com.study.tdd.api.seller.products; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; +import com.study.tdd.api.controller.response.ArrayCarrier; +import com.study.tdd.api.controller.response.SellerProductView; +import com.study.tdd.application.command.RegisterProductCommand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; + +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.SECONDS; +import static java.util.Comparator.reverseOrder; +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.springframework.http.RequestEntity.get; +import static com.study.tdd.support.ProductAssertions.isDerivedFrom; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommand; + +/** + * {@code GET /seller/products} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

        인증된 판매자가 자신이 등록한 상품 목록을 조회하는 계약을 다룬다. 다른 판매자의 상품은 + * 포함되지 않으며, 목록은 등록 시각을 기준으로 최신순(역순)으로 정렬된다.

        + * + *

        테스트 시나리오

        + *
          + *
        • 올바르게 요청하면 200 OK 상태코드를 반환한다.
        • + *
        • 판매자가 등록한 모든 상품을 반환한다.
        • + *
        • 다른 판매자가 등록한 상품이 포함되지 않는다.
        • + *
        • 상품 정보를 올바르게 반환한다.
        • + *
        • 상품 등록 시각을 올바르게 반환한다.
        • + *
        • 상품 목록을 등록 시점 역순으로 정렬한다.
        • + *
        + */ +@TddApiTest +@DisplayName("GET /seller/products") +public class GET_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + + // Act + ResponseEntity> response = + fixture.client().exchange( + get("/seller/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 판매자가_등록한_모든_상품을_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + List ids = fixture.registerProducts(); + + // Act + ResponseEntity> response = + fixture.client().exchange( + get("/seller/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + ArrayCarrier actual = response.getBody(); + assertThat(actual).isNotNull(); + assertThat(actual.items()) + .extracting(SellerProductView::id) + .containsAll(ids); + } + + @Test + void 다른_판매자가_등록한_상품이_포함되지_않는다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID unexpected = fixture.registerProduct(); + + fixture.createSellerThenSetAsDefaultUser(); + fixture.registerProducts(); + + // Act + ResponseEntity> response = + fixture.client().exchange( + get("/seller/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + assertThat(requireNonNull(response.getBody()).items()) + .extracting(SellerProductView::id) + .doesNotContain(unexpected); + } + + @Test + void 상품_정보를_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + RegisterProductCommand command = generateRegisterProductCommand(); + fixture.registerProduct(command); + + // Act + ResponseEntity> response = + fixture.client().exchange( + get("/seller/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + SellerProductView actual = requireNonNull(response.getBody()).items()[0]; + assertThat(actual).satisfies(isDerivedFrom(command)); + } + + @Test + void 상품_등록_시각을_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + LocalDateTime referenceTime = LocalDateTime.now(UTC); + fixture.registerProduct(); + + // Act + ResponseEntity> response = + fixture.client().exchange( + get("/seller/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + SellerProductView actual = requireNonNull(response.getBody()).items()[0]; + assertThat(actual.registeredTimeUtc()) + .isCloseTo(referenceTime, within(1, SECONDS)); + } + + @Test + void 상품_목록을_등록_시점_역순으로_정렬한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + fixture.registerProducts(); + + // Act + ResponseEntity> response = + fixture.client().exchange( + get("/seller/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + assertThat(requireNonNull(response.getBody()).items()) + .extracting(SellerProductView::registeredTimeUtc) + .isSortedAccordingTo(reverseOrder()); + } +} From 5911d9a678e43a2fa3c5c7535f46dc1371a46da2 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Wed, 15 Jul 2026 10:23:35 +0900 Subject: [PATCH 24/31] docs: add shopper product catalog API test scenarios --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7873f40..9fe7881 100644 --- a/README.md +++ b/README.md @@ -86,4 +86,15 @@ - 다른 판매자가 등록한 상품이 포함되지 않는다. - 상품 정보를 올바르게 반환한다. - 상품 등록 시각을 올바르게 반환한다. -- 상품 목록을 등록 시점 역순으로 정렬한다. \ No newline at end of file +- 상품 목록을 등록 시점 역순으로 정렬한다. + +# 구매자 상품 탐색 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 판매자 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다. +- 첫 번째 페이지의 상품을 반환한다. +- 상품 목록을 등록 시점 역순으로 정렬한다. +- 상품 속성을 올바르게 반환한다. +- 판매자 정보를 올바르게 반환한다. +- 두 번째 페이지를 올바르게 반환한다. +- 마지막 페이지를 올바르게 반환한다. \ No newline at end of file From 84faaec56dd70b33805ecc653ccd4db914664274 Mon Sep 17 00:00:00 2001 From: dnwls16071 Date: Wed, 15 Jul 2026 10:24:42 +0900 Subject: [PATCH 25/31] feat: implement shopper product catalog browsing API --- .../controller/ShopperProductsController.java | 29 ++ .../api/controller/response/PageCarrier.java | 9 + .../api/controller/response/ProductView.java | 37 +++ .../api/controller/response/SellerView.java | 6 + .../application/ProductCatalogService.java | 67 +++++ .../application/query/ContinuationToken.java | 45 ++++ .../tdd/application/query/GetProductPage.java | 10 + .../application/query/ProductSellerTuple.java | 13 + .../config/SecurityConfiguration.java | 1 + .../persistence/ProductRepository.java | 19 ++ .../java/com/study/tdd/api/TestFixture.java | 41 ++- .../tdd/api/TestFixtureConfiguration.java | 6 +- .../tdd/api/shopper/products/GET_specs.java | 252 ++++++++++++++++++ .../study/tdd/support/ProductAssertions.java | 13 + 14 files changed, 543 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/study/tdd/api/controller/ShopperProductsController.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/PageCarrier.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/ProductView.java create mode 100644 src/main/java/com/study/tdd/api/controller/response/SellerView.java create mode 100644 src/main/java/com/study/tdd/application/ProductCatalogService.java create mode 100644 src/main/java/com/study/tdd/application/query/ContinuationToken.java create mode 100644 src/main/java/com/study/tdd/application/query/GetProductPage.java create mode 100644 src/main/java/com/study/tdd/application/query/ProductSellerTuple.java create mode 100644 src/test/java/com/study/tdd/api/shopper/products/GET_specs.java diff --git a/src/main/java/com/study/tdd/api/controller/ShopperProductsController.java b/src/main/java/com/study/tdd/api/controller/ShopperProductsController.java new file mode 100644 index 0000000..1a8e04f --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/ShopperProductsController.java @@ -0,0 +1,29 @@ +package com.study.tdd.api.controller; + +import com.study.tdd.api.controller.response.PageCarrier; +import com.study.tdd.api.controller.response.ProductView; +import com.study.tdd.application.ProductCatalogService; +import com.study.tdd.application.query.GetProductPage; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ShopperProductsController { + + private final ProductCatalogService productCatalogService; + + @Autowired + public ShopperProductsController(ProductCatalogService productCatalogService) { + this.productCatalogService = productCatalogService; + } + + @GetMapping("/shopper/products") + PageCarrier getProducts( + @RequestParam(required = false) String continuationToken + ) { + return productCatalogService.getProductPage(new GetProductPage(continuationToken)); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/response/PageCarrier.java b/src/main/java/com/study/tdd/api/controller/response/PageCarrier.java new file mode 100644 index 0000000..96d0cc5 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/PageCarrier.java @@ -0,0 +1,9 @@ +package com.study.tdd.api.controller.response; + +/** + * 한 페이지의 항목과, 다음 페이지를 가리키는 이어보기 토큰(continuation token)을 함께 싣는 응답 봉투. + * + *

        {@code continuationToken}이 {@code null}이면 더 이상 다음 페이지가 없다는 뜻이다.

        + */ +public record PageCarrier(T[] items, String continuationToken) { +} diff --git a/src/main/java/com/study/tdd/api/controller/response/ProductView.java b/src/main/java/com/study/tdd/api/controller/response/ProductView.java new file mode 100644 index 0000000..c1ab6b2 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/ProductView.java @@ -0,0 +1,37 @@ +package com.study.tdd.api.controller.response; + +import java.math.BigDecimal; +import java.util.UUID; + +import com.study.tdd.domain.Product; +import com.study.tdd.domain.Seller; + +/** + * 구매자에게 노출되는 상품 뷰. 상품 자체의 속성과 함께, 이를 판매하는 판매자 정보({@link SellerView})를 함께 싣는다. + */ +public record ProductView( + UUID id, + SellerView seller, + String name, + String imageUri, + String description, + BigDecimal priceAmount, + int stockQuantity +) { + + public static ProductView from(Product product, Seller seller) { + return new ProductView( + product.getId(), + new SellerView( + seller.getId(), + seller.getUsername(), + seller.getContactEmail() + ), + product.getName(), + product.getImageUri(), + product.getDescription(), + product.getPriceAmount(), + product.getStockQuantity() + ); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/response/SellerView.java b/src/main/java/com/study/tdd/api/controller/response/SellerView.java new file mode 100644 index 0000000..0dcf3ae --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/SellerView.java @@ -0,0 +1,6 @@ +package com.study.tdd.api.controller.response; + +import java.util.UUID; + +public record SellerView(UUID id, String username, String contactEmail) { +} diff --git a/src/main/java/com/study/tdd/application/ProductCatalogService.java b/src/main/java/com/study/tdd/application/ProductCatalogService.java new file mode 100644 index 0000000..e374996 --- /dev/null +++ b/src/main/java/com/study/tdd/application/ProductCatalogService.java @@ -0,0 +1,67 @@ +package com.study.tdd.application; + +import java.util.List; + +import com.study.tdd.api.controller.response.PageCarrier; +import com.study.tdd.api.controller.response.ProductView; +import com.study.tdd.application.query.ContinuationToken; +import com.study.tdd.application.query.GetProductPage; +import com.study.tdd.application.query.ProductSellerTuple; +import com.study.tdd.infrastructure.persistence.ProductRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +/** + * 구매자에게 상품 목록의 한 페이지를 조립해 주는 애플리케이션 서비스. + * + *

        각 책임을 협력자에게 위임하고, 이 서비스는 "한 페이지를 조립한다"는 + * 하나의 책임만 갖는다(SRP).

        + * + *
          + *
        • 토큰 ↔ 커서 변환 → {@link ContinuationToken}
        • + *
        • 커서 기반 페이지 조회 → {@link ProductRepository#findProductPage}
        • + *
        • 조회 결과(투영) 보관 → {@link ProductSellerTuple}
        • + *
        • 투영 → 뷰 변환 → {@link ProductView#from}
        • + *
        + * + *

        다음 페이지 존재 여부를 알기 위해 한 건 더 조회한 뒤({@code PAGE_SIZE + 1}), + * 초과분의 커서를 다음 이어보기 토큰으로 삼는다.

        + */ +@Service +public class ProductCatalogService { + + private static final int PAGE_SIZE = 10; + + private final ProductRepository repository; + + @Autowired + public ProductCatalogService(ProductRepository repository) { + this.repository = repository; + } + + public PageCarrier getProductPage(GetProductPage query) { + Long cursor = ContinuationToken.decode(query.continuationToken()); + + List results = repository.findProductPage( + cursor, + PageRequest.of(0, PAGE_SIZE + 1) + ); + + ProductView[] items = results.stream() + .limit(PAGE_SIZE) + .map(tuple -> ProductView.from(tuple.product(), tuple.seller())) + .toArray(ProductView[]::new); + + return new PageCarrier<>(items, ContinuationToken.encode(nextCursor(results))); + } + + private static Long nextCursor(List results) { + if (results.size() <= PAGE_SIZE) { + return null; + } + + return results.get(results.size() - 1).product().getDataKey(); + } +} diff --git a/src/main/java/com/study/tdd/application/query/ContinuationToken.java b/src/main/java/com/study/tdd/application/query/ContinuationToken.java new file mode 100644 index 0000000..546e011 --- /dev/null +++ b/src/main/java/com/study/tdd/application/query/ContinuationToken.java @@ -0,0 +1,45 @@ +package com.study.tdd.application.query; + +import java.util.Base64; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * 페이지 커서(cursor)를 클라이언트에 노출할 문자열로 감추고 되돌리는 코덱(codec). + * + *

        내부적으로 커서는 {@code Product.dataKey}(단조 증가하는 {@code Long})지만, + * 이를 그대로 노출하면 저장소 구조가 새어 나간다. 그래서 URL-safe Base64로 + * 인코딩한 불투명 토큰(opaque token)으로 주고받는다.

        + * + *

        이 타입의 유일한 책임은 커서 ↔ 토큰의 상호 변환이다. + * 페이지를 어떻게 읽고 자르는지는 관여하지 않는다.

        + */ +public final class ContinuationToken { + + private ContinuationToken() { + } + + /** + * 토큰을 커서로 되돌린다. {@code null}이거나 빈 문자열이면 커서 없음({@code null})으로 본다. + */ + public static Long decode(String token) { + if (token == null || token.isBlank()) { + return null; + } + + byte[] data = Base64.getUrlDecoder().decode(token); + return Long.parseLong(new String(data, UTF_8)); + } + + /** + * 커서를 토큰으로 감춘다. 다음 페이지가 없어 커서가 {@code null}이면 토큰도 {@code null}이다. + */ + public static String encode(Long cursor) { + if (cursor == null) { + return null; + } + + byte[] data = cursor.toString().getBytes(UTF_8); + return Base64.getUrlEncoder().encodeToString(data); + } +} diff --git a/src/main/java/com/study/tdd/application/query/GetProductPage.java b/src/main/java/com/study/tdd/application/query/GetProductPage.java new file mode 100644 index 0000000..f114d4d --- /dev/null +++ b/src/main/java/com/study/tdd/application/query/GetProductPage.java @@ -0,0 +1,10 @@ +package com.study.tdd.application.query; + +/** + * 구매자 상품 목록의 한 페이지를 조회하는 질의(query). + * + *

        {@code continuationToken}은 이전 페이지의 마지막 지점을 가리키는 커서(cursor)를 + * 인코딩한 값이다. {@code null}이거나 빈 문자열이면 첫 번째 페이지를 뜻한다.

        + */ +public record GetProductPage(String continuationToken) { +} diff --git a/src/main/java/com/study/tdd/application/query/ProductSellerTuple.java b/src/main/java/com/study/tdd/application/query/ProductSellerTuple.java new file mode 100644 index 0000000..37d9a6f --- /dev/null +++ b/src/main/java/com/study/tdd/application/query/ProductSellerTuple.java @@ -0,0 +1,13 @@ +package com.study.tdd.application.query; + +import com.study.tdd.domain.Product; +import com.study.tdd.domain.Seller; + +/** + * 상품과 그 상품을 등록한 판매자를 함께 실어 나르는 조회 전용 투영(projection). + * + *

        {@code JOIN}으로 얻은 두 엔티티 쌍을 그대로 담기만 하는 순수 데이터 묶음이며, + * 뷰(view)로의 변환이나 커서 계산 같은 책임은 갖지 않는다.

        + */ +public record ProductSellerTuple(Product product, Seller seller) { +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index 43a7624..0968f91 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -25,6 +25,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder .requestMatchers("/seller/me").access(hasScope("seller")) .requestMatchers("/seller/products", "/seller/products/**").access(hasScope("seller")) .requestMatchers("/shopper/me").access(hasScope("shopper")) + .requestMatchers("/shopper/products").access(hasScope("shopper")) .anyRequest().authenticated() ) .build(); diff --git a/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java b/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java index c889275..93ae8b3 100644 --- a/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java +++ b/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java @@ -4,13 +4,32 @@ import java.util.Optional; import java.util.UUID; +import com.study.tdd.application.query.ProductSellerTuple; import com.study.tdd.domain.Product; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ProductRepository extends JpaRepository { Optional findById(UUID id); List findBySellerId(UUID sellerId); + + /** + * 커서 이하의 상품을 등록 시점 역순(= {@code dataKey} 내림차순)으로 판매자와 함께 조회한다. + * + *

        {@code cursor}가 {@code null}이면 첫 번째 페이지를 뜻한다. 다음 페이지 존재 여부를 + * 판별할 수 있도록, 호출부는 페이지 크기보다 하나 더 많이 요청한다({@code Pageable}).

        + */ + @Query(""" + SELECT new com.study.tdd.application.query.ProductSellerTuple(p, s) + FROM Product p + JOIN Seller s ON p.sellerId = s.id + WHERE :cursor IS NULL OR p.dataKey <= :cursor + ORDER BY p.dataKey DESC + """) + List findProductPage(@Param("cursor") Long cursor, Pageable pageable); } diff --git a/src/test/java/com/study/tdd/api/TestFixture.java b/src/test/java/com/study/tdd/api/TestFixture.java index bdfa1fe..6d519a9 100644 --- a/src/test/java/com/study/tdd/api/TestFixture.java +++ b/src/test/java/com/study/tdd/api/TestFixture.java @@ -1,10 +1,13 @@ package com.study.tdd.api; import java.net.URI; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.api.controller.response.PageCarrier; +import com.study.tdd.api.controller.response.ProductView; import com.study.tdd.api.controller.response.SellerMeView; import com.study.tdd.api.controller.response.ShopperMeView; import com.study.tdd.application.command.CreateSellerCommand; @@ -12,14 +15,17 @@ import com.study.tdd.application.command.RegisterProductCommand; import com.study.tdd.application.query.IssueSellerToken; import com.study.tdd.application.query.IssueShopperToken; +import com.study.tdd.infrastructure.persistence.ProductRepository; import org.springframework.boot.restclient.RestTemplateBuilder; import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.env.Environment; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; import static java.util.Objects.requireNonNull; +import static org.springframework.http.RequestEntity.get; import static com.study.tdd.support.EmailGenerator.generateEmail; import static com.study.tdd.support.PasswordGenerator.generatePassword; import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommand; @@ -36,12 +42,12 @@ * {@link TestRestTemplate}을 소유한다. 따라서 {@link #setShopperAsDefaultUser}처럼 * 클라이언트에 기본 인증 헤더를 심는 조작이 다른 테스트로 새지 않는다.

        */ -public record TestFixture(TestRestTemplate client) { +public record TestFixture(TestRestTemplate client, ProductRepository productRepository) { - public static TestFixture create(Environment environment) { + public static TestFixture create(Environment environment, ProductRepository productRepository) { int port = environment.getRequiredProperty("local.server.port", Integer.class); var builder = new RestTemplateBuilder().rootUri("http://localhost:" + port); - return new TestFixture(new TestRestTemplate(builder)); + return new TestFixture(new TestRestTemplate(builder), productRepository); } public void createShopper(String email, String username, String password) { @@ -131,6 +137,35 @@ public List registerProducts() { return List.of(registerProduct(), registerProduct(), registerProduct()); } + public List registerProducts(int count) { + List ids = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ids.add(registerProduct()); + } + return ids; + } + + public void deleteAllProducts() { + productRepository.deleteAll(); + } + + public String consumeProductPage() { + ResponseEntity> response = client.exchange( + get("/shopper/products").build(), + new ParameterizedTypeReference<>() { } + ); + return requireNonNull(response.getBody()).continuationToken(); + } + + public String consumeTwoProductPages() { + String token = consumeProductPage(); + ResponseEntity> response = client.exchange( + get("/shopper/products?continuationToken=" + token).build(), + new ParameterizedTypeReference<>() { } + ); + return requireNonNull(response.getBody()).continuationToken(); + } + public void setShopperAsDefaultUser(String email, String password) { setDefaultAuthorization("Bearer " + issueShopperToken(email, password)); } diff --git a/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java b/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java index c0c21b7..decde7e 100644 --- a/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java +++ b/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java @@ -1,5 +1,7 @@ package com.study.tdd.api; +import com.study.tdd.infrastructure.persistence.ProductRepository; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Scope; import org.springframework.core.env.Environment; @@ -8,7 +10,7 @@ public class TestFixtureConfiguration { @Bean @Scope("prototype") - TestFixture testFixture(Environment environment) { - return TestFixture.create(environment); + TestFixture testFixture(Environment environment, ProductRepository productRepository) { + return TestFixture.create(environment, productRepository); } } diff --git a/src/test/java/com/study/tdd/api/shopper/products/GET_specs.java b/src/test/java/com/study/tdd/api/shopper/products/GET_specs.java new file mode 100644 index 0000000..ba8fda7 --- /dev/null +++ b/src/test/java/com/study/tdd/api/shopper/products/GET_specs.java @@ -0,0 +1,252 @@ +package com.study.tdd.api.shopper.products; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; +import com.study.tdd.api.controller.response.PageCarrier; +import com.study.tdd.api.controller.response.ProductView; +import com.study.tdd.api.controller.response.SellerMeView; +import com.study.tdd.api.controller.response.SellerView; +import com.study.tdd.application.command.RegisterProductCommand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ResponseEntity; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.RequestEntity.get; +import static com.study.tdd.support.ProductAssertions.isViewDerivedFrom; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommand; + +/** + * {@code GET /shopper/products} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

        구매자가 상품 목록을 커서 기반 페이지네이션으로 탐색하는 계약을 다룬다. + * 준비(arrange)는 실제 판매자로 상품을 등록하고 구매자로 전환하는 절차를 {@link TestFixture}로 + * 끌어올려, 각 케이스는 의도만 드러내는 몇 줄로 상황을 구성한다.

        + * + *

        테스트 시나리오

        + *
          + *
        • 올바르게 요청하면 200 OK 상태코드를 반환한다.
        • + *
        • 판매자 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다.
        • + *
        • 첫 번째 페이지의 상품을 반환한다.
        • + *
        • 상품 목록을 등록 시점 역순으로 정렬한다.
        • + *
        • 상품 속성을 올바르게 반환한다.
        • + *
        • 판매자 정보를 올바르게 반환한다.
        • + *
        • 두 번째 페이지를 올바르게 반환한다.
        • + *
        • 마지막 페이지를 올바르게 반환한다.
        • + *
        + */ +@TddApiTest +@DisplayName("GET /shopper/products") +public class GET_specs { + + public static final int PAGE_SIZE = 10; + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 판매자_접근_토큰을_사용하면_403_Forbidden_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + void 첫_번째_페이지의_상품을_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.deleteAllProducts(); + + fixture.createSellerThenSetAsDefaultUser(); + List ids = fixture.registerProducts(PAGE_SIZE); + + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + PageCarrier actual = response.getBody(); + assertThat(actual).isNotNull(); + assertThat(actual.items()).extracting(ProductView::id).containsAll(ids); + } + + @Test + void 상품_목록을_등록_시점_역순으로_정렬한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.deleteAllProducts(); + + fixture.createSellerThenSetAsDefaultUser(); + UUID id1 = fixture.registerProduct(); + UUID id2 = fixture.registerProduct(); + UUID id3 = fixture.registerProduct(); + + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + assertThat(requireNonNull(response.getBody()).items()) + .extracting(ProductView::id) + .containsExactly(id3, id2, id1); + } + + @Test + void 상품_속성을_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.deleteAllProducts(); + + fixture.createSellerThenSetAsDefaultUser(); + RegisterProductCommand command = generateRegisterProductCommand(); + fixture.registerProduct(command); + + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + ProductView actual = requireNonNull(response.getBody()).items()[0]; + assertThat(actual).satisfies(isViewDerivedFrom(command)); + } + + @Test + void 판매자_정보를_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.deleteAllProducts(); + + fixture.createSellerThenSetAsDefaultUser(); + SellerMeView seller = fixture.getSeller(); + fixture.registerProduct(); + + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products").build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + SellerView actual = requireNonNull(response.getBody()).items()[0].seller(); + assertThat(actual).isNotNull(); + assertThat(actual.id()).isEqualTo(seller.id()); + assertThat(actual.username()).isEqualTo(seller.username()); + assertThat(actual.contactEmail()).isEqualTo(seller.contactEmail()); + } + + @Test + void 두_번째_페이지를_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.deleteAllProducts(); + + fixture.createSellerThenSetAsDefaultUser(); + fixture.registerProducts(PAGE_SIZE / 2); + List ids = fixture.registerProducts(PAGE_SIZE); + fixture.registerProducts(PAGE_SIZE); + + fixture.createShopperThenSetAsDefaultUser(); + String token = fixture.consumeProductPage(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products?continuationToken=" + token).build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + assertThat(requireNonNull(response.getBody()).items()) + .extracting(ProductView::id) + .containsExactlyElementsOf(reversed(ids)); + } + + @ParameterizedTest + @ValueSource(ints = { 1, PAGE_SIZE }) + void 마지막_페이지를_올바르게_반환한다( + int lastPageSize, + @Autowired TestFixture fixture + ) { + // Arrange + fixture.deleteAllProducts(); + + fixture.createSellerThenSetAsDefaultUser(); + List ids = fixture.registerProducts(lastPageSize); + fixture.registerProducts(PAGE_SIZE * 2); + + fixture.createShopperThenSetAsDefaultUser(); + String token = fixture.consumeTwoProductPages(); + + // Act + ResponseEntity> response = fixture.client().exchange( + get("/shopper/products?continuationToken=" + token).build(), + new ParameterizedTypeReference<>() { } + ); + + // Assert + PageCarrier actual = response.getBody(); + assertThat(requireNonNull(actual).items()) + .extracting(ProductView::id) + .containsExactlyElementsOf(reversed(ids)); + assertThat(actual.continuationToken()).isNull(); + } + + private static List reversed(List ids) { + List copy = new ArrayList<>(ids); + Collections.reverse(copy); + return copy; + } +} diff --git a/src/test/java/com/study/tdd/support/ProductAssertions.java b/src/test/java/com/study/tdd/support/ProductAssertions.java index 50ad4cf..6fd31b7 100644 --- a/src/test/java/com/study/tdd/support/ProductAssertions.java +++ b/src/test/java/com/study/tdd/support/ProductAssertions.java @@ -3,6 +3,7 @@ import java.math.BigDecimal; import java.util.function.Predicate; +import com.study.tdd.api.controller.response.ProductView; import com.study.tdd.api.controller.response.SellerProductView; import com.study.tdd.application.command.RegisterProductCommand; @@ -24,6 +25,18 @@ public static ThrowingConsumer isDerivedFrom( }; } + public static ThrowingConsumer isViewDerivedFrom( + RegisterProductCommand command + ) { + return product -> { + assertThat(product.name()).isEqualTo(command.name()); + assertThat(product.imageUri()).isEqualTo(command.imageUri()); + assertThat(product.description()).isEqualTo(command.description()); + assertThat(product.priceAmount()).matches(equalTo(command.priceAmount())); + assertThat(product.stockQuantity()).isEqualTo(command.stockQuantity()); + }; + } + private static Predicate equalTo(BigDecimal expected) { return actual -> actual.compareTo(expected) == 0; } From 130759038f36ce09aae1b94d5de21d9b27afe2c4 Mon Sep 17 00:00:00 2001 From: woo jin Date: Wed, 15 Jul 2026 20:51:08 +0900 Subject: [PATCH 26/31] docs: add contact email test scenarios for product inquiry --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9fe7881..40fe036 100644 --- a/README.md +++ b/README.md @@ -97,4 +97,22 @@ - 상품 속성을 올바르게 반환한다. - 판매자 정보를 올바르게 반환한다. - 두 번째 페이지를 올바르게 반환한다. -- 마지막 페이지를 올바르게 반환한다. \ No newline at end of file +- 마지막 페이지를 올바르게 반환한다. + +# 상품 문의 메일을 전송하기 위해 필요한 요구사항 + +- 구매자가 상품에 대해 궁금한 점이 있을 때 상품 정보에서 문의 이메일 주소를 확인한다. +- 판매자가 회원 가입할 때 문의 이메일 주소를 입력한다. +- 판매자가 자신의 정보를 조회할 때 문의 이메일 주소를 확인한다. + +# 판매자 회원 가입 API 문의 이메일 주소 테스트 시나리오 + +- contactEmail 속성이 올바르게 지정되지 않으면 400 Bad Request 상태코드를 반환한다. + +# 판매자 정보 조회 API 문의 이메일 주소 테스트 시나리오 + +- 판매자의 문의 이메일 주소가 올바르게 설정된다. + +# 구매자 상품 탐색 API 문의 이메일 주소 테스트 시나리오 + +- 판매자의 문의 이메일 주소가 올바르게 설정된다. \ No newline at end of file From 10788bf1ecdd9321cfd7c2bc722b3b127ce3e411 Mon Sep 17 00:00:00 2001 From: woo jin Date: Wed, 15 Jul 2026 21:04:58 +0900 Subject: [PATCH 27/31] docs: add contact email test scenarios for seller and shopper APIs --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 40fe036..e4fd56b 100644 --- a/README.md +++ b/README.md @@ -115,4 +115,10 @@ # 구매자 상품 탐색 API 문의 이메일 주소 테스트 시나리오 -- 판매자의 문의 이메일 주소가 올바르게 설정된다. \ No newline at end of file +- 판매자의 문의 이메일 주소가 올바르게 설정된다. + +# 판매자 문의 이메일 주소 변경 API 테스트 시나리오 목록 + +- 올바르게 요청하면 204 No Content 상태코드를 반환한다. +- contactEmail 속성이 올바르게 지정되지 않으면 400 Bad Request 상태코드를 반환한다. +- 문의 이메일 주소를 올바르게 변경한다. \ No newline at end of file From d3f6e440e759a1971405cd0d884422abf5170c5a Mon Sep 17 00:00:00 2001 From: woo jin Date: Wed, 15 Jul 2026 21:05:09 +0900 Subject: [PATCH 28/31] feat: implement seller contact email change API --- .../SellerChangeContactEmailController.java | 36 ++++++++ .../request/ChangeContactEmailRequest.java | 10 +++ .../SellerContactEmailService.java | 35 ++++++++ .../command/ChangeContactEmailCommand.java | 4 + .../config/SecurityConfiguration.java | 1 + .../seller/changecontactemail/POST_specs.java | 88 +++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 src/main/java/com/study/tdd/api/controller/SellerChangeContactEmailController.java create mode 100644 src/main/java/com/study/tdd/api/controller/request/ChangeContactEmailRequest.java create mode 100644 src/main/java/com/study/tdd/application/SellerContactEmailService.java create mode 100644 src/main/java/com/study/tdd/application/command/ChangeContactEmailCommand.java create mode 100644 src/test/java/com/study/tdd/api/seller/changecontactemail/POST_specs.java diff --git a/src/main/java/com/study/tdd/api/controller/SellerChangeContactEmailController.java b/src/main/java/com/study/tdd/api/controller/SellerChangeContactEmailController.java new file mode 100644 index 0000000..8e0f4a4 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerChangeContactEmailController.java @@ -0,0 +1,36 @@ +package com.study.tdd.api.controller; + +import java.security.Principal; +import java.util.UUID; + +import com.study.tdd.api.controller.request.ChangeContactEmailRequest; +import com.study.tdd.application.SellerContactEmailService; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SellerChangeContactEmailController { + + private final SellerContactEmailService sellerContactEmailService; + + @Autowired + public SellerChangeContactEmailController( + SellerContactEmailService sellerContactEmailService + ) { + this.sellerContactEmailService = sellerContactEmailService; + } + + @PostMapping("/seller/changeContactEmail") + ResponseEntity changeContactEmail( + @RequestBody ChangeContactEmailRequest request, + Principal user + ) { + UUID sellerId = UUID.fromString(user.getName()); + sellerContactEmailService.changeContactEmail(sellerId, request.toCommand()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/study/tdd/api/controller/request/ChangeContactEmailRequest.java b/src/main/java/com/study/tdd/api/controller/request/ChangeContactEmailRequest.java new file mode 100644 index 0000000..caa4d11 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/request/ChangeContactEmailRequest.java @@ -0,0 +1,10 @@ +package com.study.tdd.api.controller.request; + +import com.study.tdd.application.command.ChangeContactEmailCommand; + +public record ChangeContactEmailRequest(String contactEmail) { + + public ChangeContactEmailCommand toCommand() { + return new ChangeContactEmailCommand(contactEmail); + } +} diff --git a/src/main/java/com/study/tdd/application/SellerContactEmailService.java b/src/main/java/com/study/tdd/application/SellerContactEmailService.java new file mode 100644 index 0000000..5ee881f --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerContactEmailService.java @@ -0,0 +1,35 @@ +package com.study.tdd.application; + +import java.util.UUID; + +import com.study.tdd.application.command.ChangeContactEmailCommand; +import com.study.tdd.application.exception.BusinessException; +import com.study.tdd.application.exception.UserErrorCode; +import com.study.tdd.domain.Seller; +import com.study.tdd.infrastructure.persistence.SellerRepository; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import static com.study.tdd.domain.validation.UserPropertyValidator.isEmailValid; + +@Service +public class SellerContactEmailService { + + private final SellerRepository repository; + + @Autowired + public SellerContactEmailService(SellerRepository repository) { + this.repository = repository; + } + + public void changeContactEmail(UUID sellerId, ChangeContactEmailCommand command) { + if (!isEmailValid(command.contactEmail())) { + throw new BusinessException(UserErrorCode.INVALID_COMMAND); + } + + Seller seller = repository.findById(sellerId).orElseThrow(); + seller.setContactEmail(command.contactEmail()); + repository.save(seller); + } +} diff --git a/src/main/java/com/study/tdd/application/command/ChangeContactEmailCommand.java b/src/main/java/com/study/tdd/application/command/ChangeContactEmailCommand.java new file mode 100644 index 0000000..310e589 --- /dev/null +++ b/src/main/java/com/study/tdd/application/command/ChangeContactEmailCommand.java @@ -0,0 +1,4 @@ +package com.study.tdd.application.command; + +public record ChangeContactEmailCommand(String contactEmail) { +} diff --git a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java index 0968f91..5c11fd2 100644 --- a/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -23,6 +23,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder .requestMatchers("/shopper/signUp").permitAll() .requestMatchers("/shopper/issueToken").permitAll() .requestMatchers("/seller/me").access(hasScope("seller")) + .requestMatchers("/seller/changeContactEmail").access(hasScope("seller")) .requestMatchers("/seller/products", "/seller/products/**").access(hasScope("seller")) .requestMatchers("/shopper/me").access(hasScope("shopper")) .requestMatchers("/shopper/products").access(hasScope("shopper")) diff --git a/src/test/java/com/study/tdd/api/seller/changecontactemail/POST_specs.java b/src/test/java/com/study/tdd/api/seller/changecontactemail/POST_specs.java new file mode 100644 index 0000000..e8abd30 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/changecontactemail/POST_specs.java @@ -0,0 +1,88 @@ +package com.study.tdd.api.seller.changecontactemail; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; +import com.study.tdd.api.controller.response.SellerMeView; +import com.study.tdd.application.command.ChangeContactEmailCommand; +import com.study.tdd.support.InvalidEmailSource; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static com.study.tdd.support.EmailGenerator.generateEmail; + +/** + * {@code POST /seller/changeContactEmail} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

        인증된 판매자가 자신의 문의 이메일 주소를 변경하는 계약을 다룬다. 준비(arrange)는 + * {@link TestFixture}로 "가입 → 토큰 발급 → 기본 사용자 설정"까지 끌어올려, 각 케이스는 + * 의도만 드러내는 몇 줄로 상황을 구성한다.

        + */ +@TddApiTest +@DisplayName("POST /seller/changeContactEmail") +public class POST_specs { + + @Test + void 올바르게_요청하면_204_No_Content_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + var command = new ChangeContactEmailCommand(generateEmail()); + + // Act + ResponseEntity response = fixture.client().postForEntity( + "/seller/changeContactEmail", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(204); + } + + @ParameterizedTest + @InvalidEmailSource + void contactEmail_속성이_올바르게_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + String contactEmail, + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + var command = new ChangeContactEmailCommand(contactEmail); + + // Act + ResponseEntity response = fixture.client().postForEntity( + "/seller/changeContactEmail", + command, + Void.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 문의_이메일_주소를_올바르게_변경한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + String newContactEmail = generateEmail(); + + // Act + fixture.client().postForEntity( + "/seller/changeContactEmail", + new ChangeContactEmailCommand(newContactEmail), + Void.class + ); + + // Assert + SellerMeView actual = fixture.getSeller(); + assertThat(actual.contactEmail()).isEqualTo(newContactEmail); + } +} From 75d6a68a2b60d8c2e99ff73efb73b5371c21f2c1 Mon Sep 17 00:00:00 2001 From: woo jin Date: Wed, 15 Jul 2026 21:43:07 +0900 Subject: [PATCH 29/31] ci: add GitHub Actions Gradle build workflow --- .github/workflows/gradle.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/gradle.yml diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..557360e --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,36 @@ +name: CI with Gradle + +on: + push: + branches: ["main"] + pull_request: + branches: ['**'] + workflow_dispatch: + +jobs: + build: + name: Build with Gradle + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'corretto' + + - name: Grant execute permission to Gradle wrapper + run: chmod +x gradlew + + - name: Build with Gradle wrapper + run: ./gradlew build + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/build/reports/tests/' \ No newline at end of file From fec3c27c3a4a6c7ea67a4c3cf7be4ade8b843ee1 Mon Sep 17 00:00:00 2001 From: woo jin Date: Wed, 15 Jul 2026 22:17:31 +0900 Subject: [PATCH 30/31] test: add shopper token issuance failure contract tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 존재하지 않는 이메일로 토큰 발행 시도 → 400 Bad Request - 잘못된 비밀번호로 토큰 발행 시도 → 400 Bad Request - 올바른 자격증명 → 200 OK with JWT token - JWT 형식 검증 Seller issueToken 명세를 기준으로 작성했으며 커버리지 공백 해소 --- .../api/shopper/issueToken/POST_specs.java | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java diff --git a/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java b/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java new file mode 100644 index 0000000..a36b06a --- /dev/null +++ b/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java @@ -0,0 +1,180 @@ +package com.study.tdd.api.shopper.issueToken; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.controller.response.AccessTokenCarrier; +import com.study.tdd.application.command.CreateShopperCommand; +import com.study.tdd.application.query.IssueShopperToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.http.ResponseEntity; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static com.study.tdd.support.EmailGenerator.generateEmail; +import static com.study.tdd.support.JwtAssertions.conformsToJwtFormat; +import static com.study.tdd.support.PasswordGenerator.generatePassword; +import static com.study.tdd.support.UsernameGenerator.generateUsername; + +/** + * {@code POST /shopper/issueToken} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

        구매자 자격증명(이메일 + 비밀번호)을 검증하고 접근 토큰(access token)을 발행하는 계약을 다룬다. + * 회원가입 명세와 마찬가지로 HTTP 입출력만 관찰하는 아웃사이드-인(outside-in) 방식이다.

        + * + *

        테스트 시나리오

        + *
          + *
        • 올바르게 요청하면 200 OK 상태코드를 반환한다.
        • + *
        • 올바르게 요청하면 액세스 토큰을 반환한다.
        • + *
        • 액세스 토큰은 JWT 형식을 따른다.
        • + *
        • 존재하지 않는 이메일 주소가 사용되면 400 Bad Request 상태코드를 반환한다.
        • + *
        • 잘못된 비밀번호가 사용되면 400 Bad Request 상태코드를 반환한다.
        • + *
        + * + *

        성공 케이스는 Arrange 단계에서 먼저 회원가입을 시켜 "존재하는 구매자"라는 선행 상태를 만든다. + * 실패 계약은 자격증명이 왜 틀렸는지(이메일 없음/비밀번호 불일치)를 구분하지 않고 + * 동일하게 {@code 400}을 반환한다 — 공격자에게 계정 존재 여부를 노출하지 않기 위함이다.

        + */ +@TddApiTest +@DisplayName("POST /shopper/issueToken") +public class POST_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + password + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/issueToken", + new IssueShopperToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 올바르게_요청하면_액세스_토큰을_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + password + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/issueToken", + new IssueShopperToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().accessToken()).isNotNull(); + } + + @Test + void 액세스_토큰은_JWT_형식을_따른다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + password + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/issueToken", + new IssueShopperToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + String actual = requireNonNull(response.getBody()).accessToken(); + assertThat(actual).satisfies(conformsToJwtFormat()); + } + + @Test + void 존재하지_않는_이메일_주소가_사용되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/issueToken", + new IssueShopperToken(email, password), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void 잘못된_비밀번호가_사용되면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + String wrongPassword = generatePassword(); + + client.postForEntity( + "/shopper/signUp", + new CreateShopperCommand( + email, + generateUsername(), + password + ), + Void.class + ); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/issueToken", + new IssueShopperToken(email, wrongPassword), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } +} From 2b4e68f1da9bf911f5c07cf6c764698c0f4304a7 Mon Sep 17 00:00:00 2001 From: woo jin Date: Wed, 15 Jul 2026 22:19:11 +0900 Subject: [PATCH 31/31] test: add shopper issueToken input validation contracts --- .../api/shopper/issueToken/POST_specs.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java b/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java index a36b06a..55f0913 100644 --- a/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java +++ b/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java @@ -177,4 +177,40 @@ public class POST_specs { // Assert assertThat(response.getStatusCode().value()).isEqualTo(400); } + + @Test + void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String password = generatePassword(); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/issueToken", + new IssueShopperToken(null, password), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @Test + void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다( + @Autowired TestRestTemplate client + ) { + // Arrange + String email = generateEmail(); + + // Act + ResponseEntity response = client.postForEntity( + "/shopper/issueToken", + new IssueShopperToken(email, null), + AccessTokenCarrier.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } }