diff --git a/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaEntity.java b/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaEntity.java index 93aa2e9b4..03025542d 100644 --- a/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaEntity.java +++ b/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaEntity.java @@ -65,4 +65,7 @@ public class ProductJpaEntity { @Column(nullable = false) private Boolean isSeasonal; + + @Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT TRUE") + private Boolean isAblePurchase; } diff --git a/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaMapper.java b/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaMapper.java index 925ea973e..7d67d0a4a 100644 --- a/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaMapper.java +++ b/src/main/java/com/process/clash/adapter/persistence/shop/product/ProductJpaMapper.java @@ -26,7 +26,8 @@ public ProductJpaEntity toJpaEntity(Product product) { product.description(), product.popularity(), product.season() != null ? seasonJpaMapper.toJpaEntity(product.season()) : null, - product.isSeasonal() + product.isSeasonal(), + product.isAblePurchase() ); } @@ -48,7 +49,8 @@ public Product toDomain(ProductJpaEntity productJpaEntity) { productJpaEntity.getDescription(), productJpaEntity.getPopularity(), season, - productJpaEntity.getIsSeasonal() + productJpaEntity.getIsSeasonal(), + productJpaEntity.getIsAblePurchase() ); } } diff --git a/src/main/java/com/process/clash/adapter/web/shop/product/docs/controller/ProductAdminControllerDocument.java b/src/main/java/com/process/clash/adapter/web/shop/product/docs/controller/ProductAdminControllerDocument.java index a92ec7ea1..ada2007a8 100644 --- a/src/main/java/com/process/clash/adapter/web/shop/product/docs/controller/ProductAdminControllerDocument.java +++ b/src/main/java/com/process/clash/adapter/web/shop/product/docs/controller/ProductAdminControllerDocument.java @@ -53,7 +53,8 @@ com.process.clash.adapter.web.common.ApiResponse crea "price": 12000, "discount": 10, "description": "프로세스 삼겹살 헌터", - "seasonId": 1 + "seasonId": 1, + "isAblePurchase": true } """) )) @@ -91,7 +92,8 @@ com.process.clash.adapter.web.common.ApiResponse upda "price": 15000, "discount": 15, "description": "리마스터 버전입니다.", - "seasonId": null + "seasonId": null, + "isAblePurchase": false } """) )) diff --git a/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/CreateProductRequestDocument.java b/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/CreateProductRequestDocument.java index f637fdf33..66a15dc27 100644 --- a/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/CreateProductRequestDocument.java +++ b/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/CreateProductRequestDocument.java @@ -29,4 +29,7 @@ public class CreateProductRequestDocument { @Schema(description = "시즌 ID (시즌 상품이 아닌 경우 null)") public Long seasonId; + + @Schema(description = "구매 가능 여부 (기본값: true)", defaultValue = "true") + public Boolean isAblePurchase; } diff --git a/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/UpdateProductRequestDocument.java b/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/UpdateProductRequestDocument.java index 76ea9b993..d778ac25e 100644 --- a/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/UpdateProductRequestDocument.java +++ b/src/main/java/com/process/clash/adapter/web/shop/product/docs/request/UpdateProductRequestDocument.java @@ -29,4 +29,7 @@ public class UpdateProductRequestDocument { @Schema(description = "시즌 ID (시즌 상품이 아닌 경우 null)") public Long seasonId; + + @Schema(description = "구매 가능 여부 (null이면 기존 값 유지)") + public Boolean isAblePurchase; } diff --git a/src/main/java/com/process/clash/adapter/web/shop/product/dto/CommonProductDto.java b/src/main/java/com/process/clash/adapter/web/shop/product/dto/CommonProductDto.java index 0f2dfd2f5..13f794c10 100644 --- a/src/main/java/com/process/clash/adapter/web/shop/product/dto/CommonProductDto.java +++ b/src/main/java/com/process/clash/adapter/web/shop/product/dto/CommonProductDto.java @@ -15,6 +15,7 @@ public record CommonProductDto( Long popularity, String seasonName, Boolean isSeasonal, + Boolean isAblePurchase, Boolean isBought, Instant createdAt ) { @@ -31,6 +32,7 @@ public static CommonProductDto from(ProductVo product) { product.popularity(), product.seasonName(), product.isSeasonal(), + product.isAblePurchase(), product.isBought(), product.createdAt() ); diff --git a/src/main/java/com/process/clash/adapter/web/shop/product/dto/CreateProductDto.java b/src/main/java/com/process/clash/adapter/web/shop/product/dto/CreateProductDto.java index db78509b3..6bf0e9587 100644 --- a/src/main/java/com/process/clash/adapter/web/shop/product/dto/CreateProductDto.java +++ b/src/main/java/com/process/clash/adapter/web/shop/product/dto/CreateProductDto.java @@ -28,7 +28,8 @@ public record Request( @Max(value = 100, message = "할인율은 100 이하여야 합니다.") Integer discount, String description, - Long seasonId // 시즌 상품이 아니면 null + Long seasonId, // 시즌 상품이 아니면 null + Boolean isAblePurchase ) { public CreateProductData.Command toCommand(Actor actor) { return new CreateProductData.Command( @@ -40,7 +41,8 @@ public CreateProductData.Command toCommand(Actor actor) { price, discount, description, - seasonId + seasonId, + isAblePurchase ); } } diff --git a/src/main/java/com/process/clash/adapter/web/shop/product/dto/UpdateProductDto.java b/src/main/java/com/process/clash/adapter/web/shop/product/dto/UpdateProductDto.java index 9f7becaf2..912e28a3c 100644 --- a/src/main/java/com/process/clash/adapter/web/shop/product/dto/UpdateProductDto.java +++ b/src/main/java/com/process/clash/adapter/web/shop/product/dto/UpdateProductDto.java @@ -27,7 +27,8 @@ public record Request( @Max(value = 100, message = "할인율은 100 이하여야 합니다.") Integer discount, String description, - Long seasonId + Long seasonId, + Boolean isAblePurchase ) { public UpdateProductData.Command toCommand(Long productId, Actor actor) { return new UpdateProductData.Command( @@ -40,7 +41,8 @@ public UpdateProductData.Command toCommand(Long productId, Actor actor) { price, discount, description, - seasonId + seasonId, + isAblePurchase ); } } diff --git a/src/main/java/com/process/clash/application/shop/product/data/CreateProductData.java b/src/main/java/com/process/clash/application/shop/product/data/CreateProductData.java index 48f959247..f77aa9d1c 100644 --- a/src/main/java/com/process/clash/application/shop/product/data/CreateProductData.java +++ b/src/main/java/com/process/clash/application/shop/product/data/CreateProductData.java @@ -14,7 +14,8 @@ public record Command( Long price, Integer discount, String description, - Long seasonId + Long seasonId, + Boolean isAblePurchase ) {} public record Result( diff --git a/src/main/java/com/process/clash/application/shop/product/data/UpdateProductData.java b/src/main/java/com/process/clash/application/shop/product/data/UpdateProductData.java index fcdd2bf12..bb1ec0d9c 100644 --- a/src/main/java/com/process/clash/application/shop/product/data/UpdateProductData.java +++ b/src/main/java/com/process/clash/application/shop/product/data/UpdateProductData.java @@ -17,7 +17,8 @@ public record Command( Long price, Integer discount, String description, - Long seasonId + Long seasonId, + Boolean isAblePurchase ) {} public record Result( diff --git a/src/main/java/com/process/clash/application/shop/product/exception/exception/badrequest/NotAblePurchaseException.java b/src/main/java/com/process/clash/application/shop/product/exception/exception/badrequest/NotAblePurchaseException.java new file mode 100644 index 000000000..a277e9156 --- /dev/null +++ b/src/main/java/com/process/clash/application/shop/product/exception/exception/badrequest/NotAblePurchaseException.java @@ -0,0 +1,10 @@ +package com.process.clash.application.shop.product.exception.exception.badrequest; + +import com.process.clash.application.common.exception.exception.BadRequestException; +import com.process.clash.application.shop.product.exception.status.ProductStatusCode; + +public class NotAblePurchaseException extends BadRequestException { + public NotAblePurchaseException() { + super(ProductStatusCode.NOT_ABLE_PURCHASE); + } +} \ No newline at end of file diff --git a/src/main/java/com/process/clash/application/shop/product/exception/status/ProductStatusCode.java b/src/main/java/com/process/clash/application/shop/product/exception/status/ProductStatusCode.java index d61f4ad55..7d4e3a80d 100644 --- a/src/main/java/com/process/clash/application/shop/product/exception/status/ProductStatusCode.java +++ b/src/main/java/com/process/clash/application/shop/product/exception/status/ProductStatusCode.java @@ -13,6 +13,7 @@ public enum ProductStatusCode implements StatusCode { INVALID_TITLE("INVALID_TITLE", "상품 제목은 필수입니다.", HttpStatus.BAD_REQUEST), INVALID_PRICE("INVALID_PRICE", "가격은 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST), INVALID_DISCOUNT("INVALID_DISCOUNT", "할인율은 0~100 사이여야 합니다.", HttpStatus.BAD_REQUEST), + NOT_ABLE_PURCHASE("NOT_ABLE_PURCHASE", "구매할 수 없는 상품입니다.", HttpStatus.BAD_REQUEST), ; private final String code; diff --git a/src/main/java/com/process/clash/application/shop/product/service/CreateProductService.java b/src/main/java/com/process/clash/application/shop/product/service/CreateProductService.java index 7a8560656..eb107b6e9 100644 --- a/src/main/java/com/process/clash/application/shop/product/service/CreateProductService.java +++ b/src/main/java/com/process/clash/application/shop/product/service/CreateProductService.java @@ -39,7 +39,8 @@ public CreateProductData.Result execute(CreateProductData.Command command) { command.price(), command.discount(), command.description(), - season + season, + command.isAblePurchase() ); Product savedProduct = productRepositoryPort.save(product); diff --git a/src/main/java/com/process/clash/application/shop/product/service/UpdateProductService.java b/src/main/java/com/process/clash/application/shop/product/service/UpdateProductService.java index fe97d5024..1ec29b255 100644 --- a/src/main/java/com/process/clash/application/shop/product/service/UpdateProductService.java +++ b/src/main/java/com/process/clash/application/shop/product/service/UpdateProductService.java @@ -43,7 +43,8 @@ public UpdateProductData.Result execute(UpdateProductData.Command command) { command.price(), command.discount(), command.description(), - season + season, + command.isAblePurchase() ); Product savedProduct = productRepositoryPort.save(updatedProduct); diff --git a/src/main/java/com/process/clash/application/shop/product/vo/ProductVo.java b/src/main/java/com/process/clash/application/shop/product/vo/ProductVo.java index 47c17f475..6be43d74e 100644 --- a/src/main/java/com/process/clash/application/shop/product/vo/ProductVo.java +++ b/src/main/java/com/process/clash/application/shop/product/vo/ProductVo.java @@ -16,6 +16,7 @@ public record ProductVo( Long popularity, String seasonName, Boolean isSeasonal, + Boolean isAblePurchase, Boolean isBought, Instant createdAt ) { @@ -38,6 +39,7 @@ public static ProductVo from(Product domain, boolean isBought) { domain.popularity(), seasonName, domain.isSeasonal(), + domain.isAblePurchase(), isBought, domain.createdAt() ); diff --git a/src/main/java/com/process/clash/application/shop/purchase/service/CreatePurchaseService.java b/src/main/java/com/process/clash/application/shop/purchase/service/CreatePurchaseService.java index 41a900c5d..6045fd9c8 100644 --- a/src/main/java/com/process/clash/application/shop/purchase/service/CreatePurchaseService.java +++ b/src/main/java/com/process/clash/application/shop/purchase/service/CreatePurchaseService.java @@ -6,6 +6,7 @@ import com.process.clash.application.shop.purchase.exception.exception.badrequest.PriceTooLargeException; import com.process.clash.application.shop.purchase.port.in.CreatePurchaseUseCase; import com.process.clash.application.shop.purchase.port.out.PurchaseRepositoryPort; +import com.process.clash.application.shop.product.exception.exception.badrequest.NotAblePurchaseException; import com.process.clash.application.shop.product.exception.exception.notfound.ProductNotFoundException; import com.process.clash.application.shop.product.port.out.ProductRepositoryPort; import com.process.clash.application.user.user.exception.exception.notfound.UserNotFoundException; @@ -39,6 +40,10 @@ public CreatePurchaseData.Result execute(CreatePurchaseData.Command command) { Product product = productRepositoryPort.findById(command.productId()) .orElseThrow(ProductNotFoundException::new); + if (!product.isAblePurchase()) { + throw new NotAblePurchaseException(); + } + Long userId = command.actor().id(); if (userItemRepositoryPort.existsByUserIdAndProductId(userId, product.id())) { diff --git a/src/main/java/com/process/clash/domain/shop/product/entity/Product.java b/src/main/java/com/process/clash/domain/shop/product/entity/Product.java index b7e0c3d69..0da97aa48 100644 --- a/src/main/java/com/process/clash/domain/shop/product/entity/Product.java +++ b/src/main/java/com/process/clash/domain/shop/product/entity/Product.java @@ -20,7 +20,8 @@ public record Product( String description, Long popularity, Season season, - Boolean isSeasonal + Boolean isSeasonal, + Boolean isAblePurchase ) { public Product { if (title == null || title.isBlank()) { @@ -42,7 +43,8 @@ public static Product create( Long price, Integer discount, String description, - Season season + Season season, + Boolean isAblePurchase ) { return new Product( null, @@ -57,7 +59,8 @@ public static Product create( description, 0L, season, - season != null + season != null, + isAblePurchase != null ? isAblePurchase : true ); } @@ -69,7 +72,8 @@ public Product update( Long price, Integer discount, String description, - Season season + Season season, + Boolean isAblePurchase ) { return new Product( this.id, @@ -84,7 +88,8 @@ public Product update( description, this.popularity, season, - season != null + season != null, + isAblePurchase != null ? isAblePurchase : this.isAblePurchase ); } @@ -103,7 +108,8 @@ public Product increasePopularity() { this.description, nextPopularity, this.season, - this.isSeasonal + this.isSeasonal, + this.isAblePurchase ); } } diff --git a/src/main/resources/db/migration/V41__add_is_able_purchase_to_products.sql b/src/main/resources/db/migration/V41__add_is_able_purchase_to_products.sql new file mode 100644 index 000000000..5e89304b5 --- /dev/null +++ b/src/main/resources/db/migration/V41__add_is_able_purchase_to_products.sql @@ -0,0 +1,5 @@ +ALTER TABLE products + ADD COLUMN is_able_purchase BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE products +SET is_able_purchase = TRUE; \ No newline at end of file diff --git a/src/test/java/com/process/clash/adapter/web/shop/product/controller/ProductAdminControllerTest.java b/src/test/java/com/process/clash/adapter/web/shop/product/controller/ProductAdminControllerTest.java index b053bbcae..3e5f39ec5 100644 --- a/src/test/java/com/process/clash/adapter/web/shop/product/controller/ProductAdminControllerTest.java +++ b/src/test/java/com/process/clash/adapter/web/shop/product/controller/ProductAdminControllerTest.java @@ -77,7 +77,8 @@ void createProduct_returnsCreatedResponse() throws Exception { "price": 12000, "discount": 10, "description": "설명", - "seasonId": 1 + "seasonId": 1, + "isAblePurchase": true } """)) .andExpect(status().isCreated()) @@ -87,6 +88,7 @@ void createProduct_returnsCreatedResponse() throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(CreateProductData.Command.class); verify(createProductUseCase).execute(captor.capture()); org.assertj.core.api.Assertions.assertThat(captor.getValue().seasonId()).isEqualTo(1L); + org.assertj.core.api.Assertions.assertThat(captor.getValue().isAblePurchase()).isTrue(); } @Test @@ -97,14 +99,17 @@ void updateProduct_returnsSuccessResponse() throws Exception { mockMvc.perform(put("/api/admin/shop/products/100") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(java.util.Map.of( - "title", "수정 상품", - "category", "BANNER", - "image", "https://cdn.example.com/products/100-v2.png", - "price", 15000, - "discount", 15, - "description", "수정 설명" - )))) + .content(""" + { + "title": "수정 상품", + "category": "BANNER", + "image": "https://cdn.example.com/products/100-v2.png", + "price": 15000, + "discount": 15, + "description": "수정 설명", + "isAblePurchase": false + } + """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("상품 수정에 성공했습니다.")) .andExpect(jsonPath("$.data.productId").value(100)); @@ -113,6 +118,32 @@ void updateProduct_returnsSuccessResponse() throws Exception { verify(updateProductUseCase).execute(captor.capture()); org.assertj.core.api.Assertions.assertThat(captor.getValue().productId()).isEqualTo(100L); org.assertj.core.api.Assertions.assertThat(captor.getValue().title()).isEqualTo("수정 상품"); + org.assertj.core.api.Assertions.assertThat(captor.getValue().isAblePurchase()).isFalse(); + } + + @Test + @DisplayName("POST /api/admin/shop/products isAblePurchase 미입력 시 Command에 null로 전달된다") + void createProduct_withoutIsAblePurchase_passesNullToCommand() throws Exception { + when(createProductUseCase.execute(any())) + .thenReturn(new CreateProductData.Result(101L)); + + mockMvc.perform(post("/api/admin/shop/products") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "신규 상품", + "category": "NAMEPLATE", + "image": "https://cdn.example.com/products/101.png", + "price": 10000, + "discount": 0, + "description": "설명" + } + """)) + .andExpect(status().isCreated()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateProductData.Command.class); + verify(createProductUseCase).execute(captor.capture()); + org.assertj.core.api.Assertions.assertThat(captor.getValue().isAblePurchase()).isNull(); } @Test diff --git a/src/test/java/com/process/clash/application/profile/service/EquipMyItemServiceTest.java b/src/test/java/com/process/clash/application/profile/service/EquipMyItemServiceTest.java index d13b0f221..76d85b009 100644 --- a/src/test/java/com/process/clash/application/profile/service/EquipMyItemServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/EquipMyItemServiceTest.java @@ -147,7 +147,8 @@ private Product product(Long id, ProductCategory category) { "desc", 0L, null, - false + false, + true ); } } diff --git a/src/test/java/com/process/clash/application/profile/service/GetMyItemsServiceTest.java b/src/test/java/com/process/clash/application/profile/service/GetMyItemsServiceTest.java index 72d2e4f5a..55921c3a4 100644 --- a/src/test/java/com/process/clash/application/profile/service/GetMyItemsServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/GetMyItemsServiceTest.java @@ -81,7 +81,8 @@ private Product product(Long id, ProductCategory category) { "desc", 0L, null, - false + false, + true ); } } diff --git a/src/test/java/com/process/clash/application/profile/service/UnequipMyItemServiceTest.java b/src/test/java/com/process/clash/application/profile/service/UnequipMyItemServiceTest.java index ec7c00fb0..d8017aa82 100644 --- a/src/test/java/com/process/clash/application/profile/service/UnequipMyItemServiceTest.java +++ b/src/test/java/com/process/clash/application/profile/service/UnequipMyItemServiceTest.java @@ -78,7 +78,8 @@ private Product product(Long id, ProductCategory category) { "desc", 0L, null, - false + false, + true ); } } diff --git a/src/test/java/com/process/clash/application/shop/product/service/DeleteProductServiceTest.java b/src/test/java/com/process/clash/application/shop/product/service/DeleteProductServiceTest.java index 9609eb1d0..349c5797f 100644 --- a/src/test/java/com/process/clash/application/shop/product/service/DeleteProductServiceTest.java +++ b/src/test/java/com/process/clash/application/shop/product/service/DeleteProductServiceTest.java @@ -58,7 +58,8 @@ void execute_deletesProduct() { "설명", 0L, null, - false + false, + true ); DeleteProductData.Command command = new DeleteProductData.Command(actor, 10L); @@ -99,7 +100,8 @@ void execute_throwsConflictWhenDeleteFailsByIntegrity() { "설명", 0L, null, - false + false, + true ); DeleteProductData.Command command = new DeleteProductData.Command(actor, 10L); diff --git a/src/test/java/com/process/clash/application/shop/product/service/UpdateProductServiceTest.java b/src/test/java/com/process/clash/application/shop/product/service/UpdateProductServiceTest.java index c0777a02b..29eb2c899 100644 --- a/src/test/java/com/process/clash/application/shop/product/service/UpdateProductServiceTest.java +++ b/src/test/java/com/process/clash/application/shop/product/service/UpdateProductServiceTest.java @@ -64,9 +64,10 @@ void execute_updatesProduct() { 1000L, 10, "기존 설명", - null + null, + true ); - product = new Product(10L, Instant.now(), Instant.now(), product.title(), product.category(), product.image(), null, product.price(), product.discount(), product.description(), 15L, null, false); + product = new Product(10L, Instant.now(), Instant.now(), product.title(), product.category(), product.image(), null, product.price(), product.discount(), product.description(), 15L, null, false, true); UpdateProductData.Command command = new UpdateProductData.Command( actor, @@ -78,7 +79,8 @@ void execute_updatesProduct() { 2000L, 20, "변경 설명", - 2L + 2L, + null ); when(productRepositoryPort.findById(10L)).thenReturn(Optional.of(product)); @@ -101,6 +103,7 @@ void execute_updatesProduct() { assertThat(savedProduct.description()).isEqualTo("변경 설명"); assertThat(savedProduct.season()).isEqualTo(currentSeason); assertThat(savedProduct.isSeasonal()).isTrue(); + assertThat(savedProduct.isAblePurchase()).isTrue(); } @Test @@ -121,6 +124,7 @@ void execute_clearsSeasonWhenSeasonIdIsNull() { "설명", 3L, linkedSeason, + true, true ); @@ -134,6 +138,7 @@ void execute_clearsSeasonWhenSeasonIdIsNull() { 1000L, 5, "설명", + null, null ); @@ -163,6 +168,7 @@ void execute_throwsWhenProductNotFound() { 1000L, 5, "설명", + null, null ); @@ -189,7 +195,8 @@ void execute_savesBgmAudioUrl() { "배경음악", 0L, null, - false + false, + true ); UpdateProductData.Command command = new UpdateProductData.Command( @@ -202,6 +209,7 @@ void execute_savesBgmAudioUrl() { 5000L, 0, "배경음악", + null, null ); @@ -217,6 +225,98 @@ void execute_savesBgmAudioUrl() { assertThat(captor.getValue().audio()).isEqualTo("https://cdn.example.com/bgm.mp3"); } + @Test + @DisplayName("isAblePurchase를 false로 수정하면 저장된 상품에 반영된다") + void execute_updatesIsAblePurchaseToFalse() { + Actor actor = new Actor(1L, Role.ADMIN); + Product product = new Product( + 10L, + Instant.now(), + Instant.now(), + "상품", + ProductCategory.NAMEPLATE, + "https://cdn.example.com/product.png", + null, + 1000L, + 0, + "설명", + 0L, + null, + false, + true + ); + + UpdateProductData.Command command = new UpdateProductData.Command( + actor, + 10L, + "상품", + ProductCategory.NAMEPLATE, + "https://cdn.example.com/product.png", + null, + 1000L, + 0, + "설명", + null, + false + ); + + when(productRepositoryPort.findById(10L)).thenReturn(Optional.of(product)); + when(productRepositoryPort.save(any(Product.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + updateProductService.execute(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Product.class); + verify(productRepositoryPort).save(captor.capture()); + assertThat(captor.getValue().isAblePurchase()).isFalse(); + } + + @Test + @DisplayName("isAblePurchase가 null이면 기존 값을 유지한다") + void execute_preservesIsAblePurchaseWhenNull() { + Actor actor = new Actor(1L, Role.ADMIN); + Product product = new Product( + 10L, + Instant.now(), + Instant.now(), + "상품", + ProductCategory.NAMEPLATE, + "https://cdn.example.com/product.png", + null, + 1000L, + 0, + "설명", + 0L, + null, + false, + false + ); + + UpdateProductData.Command command = new UpdateProductData.Command( + actor, + 10L, + "상품", + ProductCategory.NAMEPLATE, + "https://cdn.example.com/product.png", + null, + 1000L, + 0, + "설명", + null, + null + ); + + when(productRepositoryPort.findById(10L)).thenReturn(Optional.of(product)); + when(productRepositoryPort.save(any(Product.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + updateProductService.execute(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Product.class); + verify(productRepositoryPort).save(captor.capture()); + assertThat(captor.getValue().isAblePurchase()).isFalse(); + } + @Test @DisplayName("존재하지 않는 시즌 ID면 예외가 발생한다") void execute_throwsWhenSeasonNotFound() { @@ -234,7 +334,8 @@ void execute_throwsWhenSeasonNotFound() { "설명", 3L, null, - false + false, + true ); UpdateProductData.Command command = new UpdateProductData.Command( actor, @@ -246,7 +347,8 @@ void execute_throwsWhenSeasonNotFound() { 1000L, 5, "설명", - 999L + 999L, + null ); when(productRepositoryPort.findById(10L)).thenReturn(Optional.of(product)); diff --git a/src/test/java/com/process/clash/application/shop/purchase/service/CreatePurchaseServiceTest.java b/src/test/java/com/process/clash/application/shop/purchase/service/CreatePurchaseServiceTest.java new file mode 100644 index 000000000..8bc44d84b --- /dev/null +++ b/src/test/java/com/process/clash/application/shop/purchase/service/CreatePurchaseServiceTest.java @@ -0,0 +1,126 @@ +package com.process.clash.application.shop.purchase.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.process.clash.application.common.actor.Actor; +import com.process.clash.application.shop.product.exception.exception.badrequest.NotAblePurchaseException; +import com.process.clash.application.shop.product.exception.exception.notfound.ProductNotFoundException; +import com.process.clash.application.shop.product.port.out.ProductRepositoryPort; +import com.process.clash.application.shop.purchase.data.CreatePurchaseData; +import com.process.clash.application.shop.purchase.exception.exception.conflict.AlreadyOwnedProductException; +import com.process.clash.application.shop.purchase.port.out.PurchaseRepositoryPort; +import com.process.clash.application.user.user.port.out.UserRepositoryPort; +import com.process.clash.application.user.usergoodshistory.port.out.UserGoodsHistoryRepositoryPort; +import com.process.clash.application.user.useritem.port.out.UserItemRepositoryPort; +import com.process.clash.domain.shop.product.entity.Product; +import com.process.clash.domain.shop.product.enums.ProductCategory; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CreatePurchaseServiceTest { + + @Mock + private ProductRepositoryPort productRepositoryPort; + + @Mock + private PurchaseRepositoryPort purchaseRepositoryPort; + + @Mock + private UserRepositoryPort userRepositoryPort; + + @Mock + private UserGoodsHistoryRepositoryPort userGoodsHistoryRepositoryPort; + + @Mock + private UserItemRepositoryPort userItemRepositoryPort; + + private CreatePurchaseService createPurchaseService; + + @BeforeEach + void setUp() { + createPurchaseService = new CreatePurchaseService( + productRepositoryPort, + purchaseRepositoryPort, + userRepositoryPort, + userGoodsHistoryRepositoryPort, + userItemRepositoryPort + ); + } + + @Test + @DisplayName("상품을 찾지 못하면 예외가 발생한다") + void execute_throwsWhenProductNotFound() { + Actor actor = new Actor(1L); + CreatePurchaseData.Command command = new CreatePurchaseData.Command(actor, 99L); + + when(productRepositoryPort.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> createPurchaseService.execute(command)) + .isInstanceOf(ProductNotFoundException.class); + } + + @Test + @DisplayName("isAblePurchase가 false인 상품은 구매할 수 없다") + void execute_throwsWhenProductNotAblePurchase() { + Actor actor = new Actor(1L); + Product product = new Product( + 10L, + Instant.now(), + Instant.now(), + "구매 불가 상품", + ProductCategory.NAMEPLATE, + "https://cdn.example.com/product.png", + null, + 1000L, + 0, + "설명", + 0L, + null, + false, + false + ); + CreatePurchaseData.Command command = new CreatePurchaseData.Command(actor, 10L); + + when(productRepositoryPort.findById(10L)).thenReturn(Optional.of(product)); + + assertThatThrownBy(() -> createPurchaseService.execute(command)) + .isInstanceOf(NotAblePurchaseException.class); + } + + @Test + @DisplayName("이미 보유한 상품은 구매할 수 없다") + void execute_throwsWhenAlreadyOwned() { + Actor actor = new Actor(1L); + Product product = new Product( + 10L, + Instant.now(), + Instant.now(), + "상품", + ProductCategory.NAMEPLATE, + "https://cdn.example.com/product.png", + null, + 1000L, + 0, + "설명", + 0L, + null, + false, + true + ); + CreatePurchaseData.Command command = new CreatePurchaseData.Command(actor, 10L); + + when(productRepositoryPort.findById(10L)).thenReturn(Optional.of(product)); + when(userItemRepositoryPort.existsByUserIdAndProductId(1L, 10L)).thenReturn(true); + + assertThatThrownBy(() -> createPurchaseService.execute(command)) + .isInstanceOf(AlreadyOwnedProductException.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/shop/recommendedproduct/service/GetRecommendedProductsServiceTest.java b/src/test/java/com/process/clash/application/shop/recommendedproduct/service/GetRecommendedProductsServiceTest.java index fdd151972..281f2b9e2 100644 --- a/src/test/java/com/process/clash/application/shop/recommendedproduct/service/GetRecommendedProductsServiceTest.java +++ b/src/test/java/com/process/clash/application/shop/recommendedproduct/service/GetRecommendedProductsServiceTest.java @@ -97,7 +97,8 @@ void execute_preservesRecommendationOrder() { "상품100 설명", 0L, null, - false + false, + true ); Product product200 = new Product( 200L, @@ -112,7 +113,8 @@ void execute_preservesRecommendationOrder() { "상품200 설명", 0L, null, - false + false, + true ); when(recommendedProductRepositoryPort.findTop10ActiveByDateOrderByDisplayOrder(any()))