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 diff --git a/README.md b/README.md index 237aa0d..e4fd56b 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,110 @@ - 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 상태코드를 반환한다. + +# 구매자 회원 가입 정책 + +- 이메일 주소는 유일해야 한다. +- 사용자 이름은 유일해야 한다. +- 사용자 이름은 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 상태코드를 반환한다. +- 비밀번호를 올바르게 암호화한다. + +# 판매자 정보 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다. +- 서로 다른 판매자의 식별자는 서로 다르다. +- 같은 판매자의 식별자는 항상 같아야 한다. +- 판매자의 기본 정보가 올바르게 설정된다. + +# 구매자 정보 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다. +- 서로 다른 구매자의 식별자는 서로 다르다. +- 같은 구매자의 식별자는 항상 같아야 한다. +- 구매자의 기본 정보가 올바르게 설정된다. + +# 판매자 상품 등록 API 테스트 시나리오 목록 + +- 올바르게 요청하면 201 Created 상태코드를 반환한다. +- 판매자가 아닌 사용자의 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다. +- imageUri 속성이 URI 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다. +- 올바르게 요청하면 등록된 상품 정보에 접근하는 Location 헤더를 반환한다. + +# 판매자 상품 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 판매자가 아닌 사용자의 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다. +- 존재하지 않는 식별자를 사용하면 404 Not Found 상태코드를 반환한다. +- 다른 판매자가 등록한 상품 식별자를 사용하면 404 Not Found 상태코드를 반환한다. +- 상품 식별자를 올바르게 반환한다. +- 상품 정보를 올바르게 반환한다. +- 상품 등록 시각을 올바르게 반환한다. + +# 판매자 상품 목록 조회 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 판매자가 등록한 모든 상품을 반환한다. +- 다른 판매자가 등록한 상품이 포함되지 않는다. +- 상품 정보를 올바르게 반환한다. +- 상품 등록 시각을 올바르게 반환한다. +- 상품 목록을 등록 시점 역순으로 정렬한다. + +# 구매자 상품 탐색 API 테스트 시나리오 목록 + +- 올바르게 요청하면 200 OK 상태코드를 반환한다. +- 판매자 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다. +- 첫 번째 페이지의 상품을 반환한다. +- 상품 목록을 등록 시점 역순으로 정렬한다. +- 상품 속성을 올바르게 반환한다. +- 판매자 정보를 올바르게 반환한다. +- 두 번째 페이지를 올바르게 반환한다. +- 마지막 페이지를 올바르게 반환한다. + +# 상품 문의 메일을 전송하기 위해 필요한 요구사항 + +- 구매자가 상품에 대해 궁금한 점이 있을 때 상품 정보에서 문의 이메일 주소를 확인한다. +- 판매자가 회원 가입할 때 문의 이메일 주소를 입력한다. +- 판매자가 자신의 정보를 조회할 때 문의 이메일 주소를 확인한다. + +# 판매자 회원 가입 API 문의 이메일 주소 테스트 시나리오 + +- contactEmail 속성이 올바르게 지정되지 않으면 400 Bad Request 상태코드를 반환한다. + +# 판매자 정보 조회 API 문의 이메일 주소 테스트 시나리오 + +- 판매자의 문의 이메일 주소가 올바르게 설정된다. + +# 구매자 상품 탐색 API 문의 이메일 주소 테스트 시나리오 + +- 판매자의 문의 이메일 주소가 올바르게 설정된다. + +# 판매자 문의 이메일 주소 변경 API 테스트 시나리오 목록 + +- 올바르게 요청하면 204 No Content 상태코드를 반환한다. +- contactEmail 속성이 올바르게 지정되지 않으면 400 Bad Request 상태코드를 반환한다. +- 문의 이메일 주소를 올바르게 변경한다. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 329c5b5..a4b31f3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,32 +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' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - 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/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/SellerIssueTokenController.java b/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java new file mode 100644 index 0000000..2f70aea --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerIssueTokenController.java @@ -0,0 +1,31 @@ +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/SellerMeController.java b/src/main/java/com/study/tdd/api/controller/SellerMeController.java new file mode 100644 index 0000000..957b732 --- /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.application.SellerProfileService; +import com.study.tdd.domain.Seller; + +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 SellerProfileService sellerProfileService; + + @Autowired + public SellerMeController(SellerProfileService sellerProfileService) { + this.sellerProfileService = sellerProfileService; + } + + @GetMapping("/seller/me") + SellerMeView me(Principal user) { + UUID id = UUID.fromString(user.getName()); + Seller seller = sellerProfileService.findSeller(id).orElseThrow(); + return new SellerMeView( + id, + seller.getEmail(), + seller.getUsername(), + seller.getContactEmail() + ); + } +} 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..13d6b08 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerProductController.java @@ -0,0 +1,38 @@ +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 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(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/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/SellerSignUpController.java b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java new file mode 100644 index 0000000..15738e2 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/SellerSignUpController.java @@ -0,0 +1,27 @@ +package com.study.tdd.api.controller; + +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 class SellerSignUpController { + + private final 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(); + } +} 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/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/ShopperSignUpController.java b/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java new file mode 100644 index 0000000..2dc8b2e --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/ShopperSignUpController.java @@ -0,0 +1,27 @@ +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/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/api/controller/request/CreateSellerRequest.java b/src/main/java/com/study/tdd/api/controller/request/CreateSellerRequest.java new file mode 100644 index 0000000..ab04bd0 --- /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/controller/request/CreateShopperRequest.java b/src/main/java/com/study/tdd/api/controller/request/CreateShopperRequest.java new file mode 100644 index 0000000..f1a6f4e --- /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/api/controller/request/IssueSellerTokenRequest.java b/src/main/java/com/study/tdd/api/controller/request/IssueSellerTokenRequest.java new file mode 100644 index 0000000..698b70b --- /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/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/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/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/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/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/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/api/controller/response/SellerProductView.java b/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java new file mode 100644 index 0000000..af5e196 --- /dev/null +++ b/src/main/java/com/study/tdd/api/controller/response/SellerProductView.java @@ -0,0 +1,30 @@ +package com.study.tdd.api.controller.response; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import com.study.tdd.domain.Product; + +public record SellerProductView( + UUID id, + String name, + String imageUri, + String description, + BigDecimal priceAmount, + 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/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/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/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..85eda79 --- /dev/null +++ b/src/main/java/com/study/tdd/api/exception/GlobalExceptionHandler.java @@ -0,0 +1,20 @@ +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/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/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/SellerIssueTokenService.java b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java new file mode 100644 index 0000000..c295956 --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerIssueTokenService.java @@ -0,0 +1,41 @@ +package com.study.tdd.application; + +import java.util.Optional; + +import com.study.tdd.application.query.IssueSellerToken; +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; +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 TokenIssuer tokenIssuer; + + @Autowired + public SellerIssueTokenService( + PasswordEncoder passwordEncoder, + SellerRepository repository, + TokenIssuer tokenIssuer + ) { + this.passwordEncoder = passwordEncoder; + this.repository = repository; + this.tokenIssuer = tokenIssuer; + } + + public Optional issueToken(IssueSellerToken query) { + return repository + .findByEmail(query.email()) + .filter(seller -> passwordEncoder.matches( + query.password(), + seller.getHashedPassword() + )) + .map(seller -> tokenIssuer.issueToken(seller.getId(), TokenScope.SELLER)); + } +} 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..d2aad19 --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerProductQueryService.java @@ -0,0 +1,40 @@ +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 { + + 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())); + } + + 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/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/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); + } +} 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..6dd9d29 --- /dev/null +++ b/src/main/java/com/study/tdd/application/SellerSignUpService.java @@ -0,0 +1,67 @@ +package com.study.tdd.application; + +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; + +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 BusinessException(UserErrorCode.INVALID_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) { + 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/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/ShopperSignUpService.java b/src/main/java/com/study/tdd/application/ShopperSignUpService.java new file mode 100644 index 0000000..1a7cba2 --- /dev/null +++ b/src/main/java/com/study/tdd/application/ShopperSignUpService.java @@ -0,0 +1,65 @@ +package com.study.tdd.application; + +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; + +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 BusinessException(UserErrorCode.INVALID_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) { + 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/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/application/command/CreateSellerCommand.java b/src/main/java/com/study/tdd/application/command/CreateSellerCommand.java new file mode 100644 index 0000000..3e09175 --- /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/application/command/CreateShopperCommand.java b/src/main/java/com/study/tdd/application/command/CreateShopperCommand.java new file mode 100644 index 0000000..b5211d8 --- /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/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/BusinessException.java b/src/main/java/com/study/tdd/application/exception/BusinessException.java new file mode 100644 index 0000000..c50caeb --- /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..3f3ff4c --- /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/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/exception/UserErrorCode.java b/src/main/java/com/study/tdd/application/exception/UserErrorCode.java new file mode 100644 index 0000000..4894820 --- /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; + } +} 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/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/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/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/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/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/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/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..4bc47a3 --- /dev/null +++ b/src/main/java/com/study/tdd/application/security/TokenScope.java @@ -0,0 +1,17 @@ +package com.study.tdd.application.security; + +public enum TokenScope { + + SELLER("seller"), + SHOPPER("shopper"); + + private final String value; + + TokenScope(String value) { + this.value = value; + } + + public String value() { + return value; + } +} 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/Seller.java b/src/main/java/com/study/tdd/domain/Seller.java new file mode 100644 index 0000000..6291322 --- /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/Shopper.java b/src/main/java/com/study/tdd/domain/Shopper.java new file mode 100644 index 0000000..5acd5ab --- /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/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/domain/validation/UserPropertyValidator.java b/src/main/java/com/study/tdd/domain/validation/UserPropertyValidator.java new file mode 100644 index 0000000..2d82b1f --- /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/JwtConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java new file mode 100644 index 0000000..ef9390b --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/config/JwtConfiguration.java @@ -0,0 +1,27 @@ +package com.study.tdd.infrastructure.config; + +import java.nio.charset.StandardCharsets; + +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; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +@Configuration +public class JwtConfiguration { + + @Bean + 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/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/PasswordEncoderConfiguration.java b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java new file mode 100644 index 0000000..bdb186d --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/config/PasswordEncoderConfiguration.java @@ -0,0 +1,15 @@ +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; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfiguration { + + @Bean + PasswordEncoder 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..5c11fd2 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/config/SecurityConfiguration.java @@ -0,0 +1,34 @@ +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.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, 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("/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")) + .anyRequest().authenticated() + ) + .build(); + } +} diff --git a/src/main/java/com/study/tdd/infrastructure/jwt/JwtTokenIssuer.java b/src/main/java/com/study/tdd/infrastructure/jwt/JwtTokenIssuer.java new file mode 100644 index 0000000..ec04260 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/jwt/JwtTokenIssuer.java @@ -0,0 +1,33 @@ +package com.study.tdd.infrastructure.jwt; + +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; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenIssuer implements TokenIssuer { + + private final JwtKeyHolder jwtKeyHolder; + + @Autowired + public JwtTokenIssuer(JwtKeyHolder jwtKeyHolder) { + this.jwtKeyHolder = jwtKeyHolder; + } + + @Override + public String issueToken(UUID subjectId, TokenScope scope) { + return Jwts + .builder() + .subject(subjectId.toString()) + .claim("scp", scope.value()) + .signWith(jwtKeyHolder.key()) + .compact(); + } +} 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..93ae8b3 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/persistence/ProductRepository.java @@ -0,0 +1,35 @@ +package com.study.tdd.infrastructure.persistence; + +import java.util.List; +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/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..c638aa9 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/persistence/SellerRepository.java @@ -0,0 +1,15 @@ +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/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..28ec618 --- /dev/null +++ b/src/main/java/com/study/tdd/infrastructure/persistence/ShopperRepository.java @@ -0,0 +1,15 @@ +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/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/TddApiTest.java b/src/test/java/com/study/tdd/api/TddApiTest.java new file mode 100644 index 0000000..b373d0a --- /dev/null +++ b/src/test/java/com/study/tdd/api/TddApiTest.java @@ -0,0 +1,43 @@ +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}로 주입해 테스트 속도를 확보한다.
  • + *
  • {@link TestFixtureConfiguration} — "가입 → 토큰 발급 → 인증된 요청" 준비 절차를 + * 한 줄로 끌어올린 {@link TestFixture}를 테스트마다 새로({@code prototype}) 주입한다.
  • + *
  • {@code @AutoConfigureTestRestTemplate} — Spring Boot 4.1에서 분리된 + * {@code TestRestTemplate} 빈을 등록한다.
  • + *
+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest( + classes = { + TddApplication.class, + TestPasswordEncoderConfiguration.class, + TestFixtureConfiguration.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@AutoConfigureTestRestTemplate +public @interface TddApiTest { +} 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..6d519a9 --- /dev/null +++ b/src/test/java/com/study/tdd/api/TestFixture.java @@ -0,0 +1,203 @@ +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; +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; +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; +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, ProductRepository productRepository) { + + 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), productRepository); + } + + 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 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 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)); + } + + 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..decde7e --- /dev/null +++ b/src/test/java/com/study/tdd/api/TestFixtureConfiguration.java @@ -0,0 +1,16 @@ +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; + +public class TestFixtureConfiguration { + + @Bean + @Scope("prototype") + TestFixture testFixture(Environment environment, ProductRepository productRepository) { + return TestFixture.create(environment, productRepository); + } +} 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); + } +} 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..051b9c1 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/issueToken/POST_specs.java @@ -0,0 +1,184 @@ +package com.study.tdd.api.seller.issueToken; + +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 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 /seller/issueToken} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

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

+ * + *

테스트 시나리오

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

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

+ */ +@TddApiTest +@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/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..d9992a6 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/me/GET_specs.java @@ -0,0 +1,140 @@ +package com.study.tdd.api.seller.me; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; +import com.study.tdd.api.controller.response.SellerMeView; + +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.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 입출력만 관찰한다.

+ * + *

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

+ * + *

테스트 시나리오

+ *
    + *
  • 올바르게 요청하면 200 OK 상태코드를 반환한다.
  • + *
  • 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다.
  • + *
  • 서로 다른 판매자의 식별자는 서로 다르다.
  • + *
  • 같은 판매자의 식별자는 항상 같아야 한다.
  • + *
  • 판매자의 기본 정보가 올바르게 설정된다.
  • + *
+ */ +@TddApiTest +@DisplayName("GET /seller/me") +public class GET_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + String token = fixture.createSellerThenIssueToken(); + + // Act + ResponseEntity response = fixture.client().exchange( + get("/seller/me") + .header("Authorization", "Bearer " + token) + .build(), + SellerMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 액세스_토큰을_사용하지_않으면_401_Unauthorized_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/me", + SellerMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + void 서로_다른_판매자의_식별자는_서로_다르다( + @Autowired TestFixture fixture + ) { + // Arrange + String token1 = fixture.createSellerThenIssueToken(); + String token2 = fixture.createSellerThenIssueToken(); + + // Act + SellerMeView seller1 = getSellerMe(fixture, token1); + SellerMeView seller2 = getSellerMe(fixture, token2); + + // Assert + assertThat(seller1.id()).isNotEqualTo(seller2.id()); + } + + @Test + void 같은_판매자의_식별자는_항상_같다( + @Autowired TestFixture fixture + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + fixture.createSeller(email, generateUsername(), password, generateEmail()); + + String token1 = fixture.issueSellerToken(email, password); + String token2 = fixture.issueSellerToken(email, password); + + // Act + SellerMeView seller1 = getSellerMe(fixture, token1); + SellerMeView seller2 = getSellerMe(fixture, token2); + + // Assert + assertThat(seller1.id()).isEqualTo(seller2.id()); + } + + @Test + void 판매자의_기본_정보가_올바르게_설정된다( + @Autowired TestFixture fixture + ) { + // Arrange + String email = generateEmail(); + String username = generateUsername(); + String password = generatePassword(); + String contactEmail = generateEmail(); + fixture.createSeller(email, username, password, contactEmail); + fixture.setSellerAsDefaultUser(email, password); + + // Act + SellerMeView actual = fixture.getSeller(); + + // Assert + assertThat(actual.email()).isEqualTo(email); + assertThat(actual.username()).isEqualTo(username); + assertThat(actual.contactEmail()).isEqualTo(contactEmail); + } + + private static SellerMeView getSellerMe(TestFixture fixture, String token) { + ResponseEntity response = fixture.client().exchange( + get("/seller/me") + .header("Authorization", "Bearer " + token) + .build(), + SellerMeView.class + ); + return requireNonNull(response.getBody()); + } +} 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()); + } +} diff --git a/src/test/java/com/study/tdd/api/seller/products/POST_specs.java b/src/test/java/com/study/tdd/api/seller/products/POST_specs.java new file mode 100644 index 0000000..5d965c2 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/products/POST_specs.java @@ -0,0 +1,134 @@ +package com.study.tdd.api.seller.products; + +import java.net.URI; +import java.util.UUID; +import java.util.function.Predicate; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommand; +import static com.study.tdd.support.RegisterProductCommandGenerator.generateRegisterProductCommandWithImageUri; + +/** + * {@code POST /seller/products} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

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

+ * + *

테스트 시나리오

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

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

+ * + *

테스트 시나리오

+ *
    + *
  • 올바르게 요청하면 200 OK 상태코드를 반환한다.
  • + *
  • 판매자가 아닌 사용자의 접근 토큰을 사용하면 403 Forbidden 상태코드를 반환한다.
  • + *
  • 존재하지 않는 식별자를 사용하면 404 Not Found 상태코드를 반환한다.
  • + *
  • 다른 판매자가 등록한 상품 식별자를 사용하면 404 Not Found 상태코드를 반환한다.
  • + *
  • 상품 식별자를 올바르게 반환한다.
  • + *
  • 상품 정보를 올바르게 반환한다.
  • + *
  • 상품 등록 시각을 올바르게 반환한다.
  • + *
+ */ +@TddApiTest +@DisplayName("GET /seller/products/{id}") +public class GET_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 판매자가_아닌_사용자의_접근_토큰을_사용하면_403_Forbidden_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + fixture.createShopperThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + void 존재하지_않는_식별자를_사용하면_404_Not_Found_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = UUID.randomUUID(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + @Test + void 다른_판매자가_등록한_상품_식별자를_사용하면_404_Not_Found_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + fixture.createSellerThenSetAsDefaultUser(); + + // Act + ResponseEntity response = fixture.client().getForEntity( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + @Test + void 상품_식별자를_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + UUID id = fixture.registerProduct(); + + // Act + SellerProductView actual = fixture.client().getForObject( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(actual).isNotNull(); + assertThat(actual.id()).isEqualTo(id); + } + + @Test + void 상품_정보를_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + RegisterProductCommand command = generateRegisterProductCommand(); + UUID id = fixture.registerProduct(command); + + // Act + SellerProductView actual = fixture.client().getForObject( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(actual).satisfies(isDerivedFrom(command)); + } + + @Test + void 상품_등록_시각을_올바르게_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + fixture.createSellerThenSetAsDefaultUser(); + LocalDateTime referenceTime = LocalDateTime.now(UTC); + UUID id = fixture.registerProduct(); + + // Act + SellerProductView actual = fixture.client().getForObject( + "/seller/products/" + id, + SellerProductView.class + ); + + // Assert + assertThat(actual.registeredTimeUtc()) + .isCloseTo(referenceTime, within(1, SECONDS)); + } +} diff --git a/src/test/java/com/study/tdd/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..2629d07 --- /dev/null +++ b/src/test/java/com/study/tdd/api/seller/signup/POST_specs.java @@ -0,0 +1,389 @@ +package com.study.tdd.api.seller.signup; + +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.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.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 /seller/signUp} 엔드포인트의 명세(specification)를 검증하는 통합 테스트. + * + *

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

+ * + *

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

+ * + *

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

+ */ +@TddApiTest +@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 + @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/issueToken/POST_specs.java b/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java new file mode 100644 index 0000000..55f0913 --- /dev/null +++ b/src/test/java/com/study/tdd/api/shopper/issueToken/POST_specs.java @@ -0,0 +1,216 @@ +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); + } + + @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); + } +} 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..b1d9581 --- /dev/null +++ b/src/test/java/com/study/tdd/api/shopper/me/GET_specs.java @@ -0,0 +1,139 @@ +package com.study.tdd.api.shopper.me; + +import com.study.tdd.api.TddApiTest; +import com.study.tdd.api.TestFixture; +import com.study.tdd.api.controller.response.ShopperMeView; + +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.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) 속성이 없다는 점이 다르다.

+ * + *

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

+ * + *

테스트 시나리오

+ *
    + *
  • 올바르게 요청하면 200 OK 상태코드를 반환한다.
  • + *
  • 액세스 토큰을 사용하지 않으면 401 Unauthorized 상태코드를 반환한다.
  • + *
  • 서로 다른 구매자의 식별자는 서로 다르다.
  • + *
  • 같은 구매자의 식별자는 항상 같아야 한다.
  • + *
  • 구매자의 기본 정보가 올바르게 설정된다.
  • + *
+ */ +@TddApiTest +@DisplayName("GET /shopper/me") +public class GET_specs { + + @Test + void 올바르게_요청하면_200_OK_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Arrange + String token = fixture.createShopperThenIssueToken(); + + // Act + ResponseEntity response = fixture.client().exchange( + get("/shopper/me") + .header("Authorization", "Bearer " + token) + .build(), + ShopperMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + void 액세스_토큰을_사용하지_않으면_401_Unauthorized_상태코드를_반환한다( + @Autowired TestFixture fixture + ) { + // Act + ResponseEntity response = fixture.client().getForEntity( + "/shopper/me", + ShopperMeView.class + ); + + // Assert + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + void 서로_다른_구매자의_식별자는_서로_다르다( + @Autowired TestFixture fixture + ) { + // Arrange + String token1 = fixture.createShopperThenIssueToken(); + String token2 = fixture.createShopperThenIssueToken(); + + // Act + ShopperMeView shopper1 = getShopperMe(fixture, token1); + ShopperMeView shopper2 = getShopperMe(fixture, token2); + + // Assert + assertThat(shopper1.id()).isNotEqualTo(shopper2.id()); + } + + @Test + void 같은_구매자의_식별자는_항상_같다( + @Autowired TestFixture fixture + ) { + // Arrange + String email = generateEmail(); + String password = generatePassword(); + fixture.createShopper(email, generateUsername(), password); + + String token1 = fixture.issueShopperToken(email, password); + String token2 = fixture.issueShopperToken(email, password); + + // Act + ShopperMeView shopper1 = getShopperMe(fixture, token1); + ShopperMeView shopper2 = getShopperMe(fixture, token2); + + // Assert + assertThat(shopper1.id()).isEqualTo(shopper2.id()); + } + + @Test + void 구매자의_기본_정보가_올바르게_설정된다( + @Autowired TestFixture fixture + ) { + // Arrange + String email = generateEmail(); + String username = generateUsername(); + String password = generatePassword(); + fixture.createShopper(email, username, password); + fixture.setShopperAsDefaultUser(email, password); + + // Act + ShopperMeView actual = fixture.getShopper(); + + // Assert + assertThat(actual.email()).isEqualTo(email); + assertThat(actual.username()).isEqualTo(username); + } + + private static ShopperMeView getShopperMe(TestFixture fixture, String token) { + ResponseEntity response = fixture.client().exchange( + get("/shopper/me") + .header("Authorization", "Bearer " + token) + .build(), + ShopperMeView.class + ); + return requireNonNull(response.getBody()); + } +} 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/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..ee45de0 --- /dev/null +++ b/src/test/java/com/study/tdd/api/shopper/signup/POST_specs.java @@ -0,0 +1,343 @@ +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 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.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 + @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/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/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 { +} 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; + } + } +} 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/ProductAssertions.java b/src/test/java/com/study/tdd/support/ProductAssertions.java new file mode 100644 index 0000000..6fd31b7 --- /dev/null +++ b/src/test/java/com/study/tdd/support/ProductAssertions.java @@ -0,0 +1,43 @@ +package com.study.tdd.support; + +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; + +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()); + }; + } + + public static ThrowingConsumer isViewDerivedFrom( + RegisterProductCommand command + ) { + return product -> { + assertThat(product.name()).isEqualTo(command.name()); + assertThat(product.imageUri()).isEqualTo(command.imageUri()); + assertThat(product.description()).isEqualTo(command.description()); + assertThat(product.priceAmount()).matches(equalTo(command.priceAmount())); + assertThat(product.stockQuantity()).isEqualTo(command.stockQuantity()); + }; + } + + private static Predicate equalTo(BigDecimal expected) { + return actual -> actual.compareTo(expected) == 0; + } +} 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); + } +} 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..c8a6f63 --- /dev/null +++ b/src/test/java/com/study/tdd/support/TestPasswordEncoderConfiguration.java @@ -0,0 +1,14 @@ +package com.study.tdd.support; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +public class TestPasswordEncoderConfiguration { + + @Bean + @Primary + BCryptPasswordEncoder testPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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(); + } +}