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