From 6db9153e2bdd08feed44a87c98a320469cb7a364 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 13 Mar 2026 08:46:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20ProductSortType.LATEST=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductService: getById() → getProduct() 리네임, getProductForAdmin() 추가 - ProductSortType: LATEST 값 추가, ProductRepositoryImpl에 LATEST 케이스 처리 - BrandFacade: deleteAllByBrandId() → softDeleteByBrandId() 호출 수정 - ProductRepositoryImpl: 중복 save() 메서드 제거 - 테스트 5개 파일: 메서드명 불일치 일괄 수정 Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/brand/BrandFacade.java | 2 +- .../application/product/ProductService.java | 12 +++++++++--- .../loopers/domain/product/ProductSortType.java | 1 + .../product/ProductRepositoryImpl.java | 7 +------ .../application/brand/BrandFacadeTest.java | 2 +- .../loopers/application/like/LikeFacadeTest.java | 16 ++++++++-------- .../application/order/OrderFacadeTest.java | 8 ++++---- .../product/ProductServiceIntegrationTest.java | 2 +- .../application/product/ProductServiceTest.java | 10 +++++----- 9 files changed, 31 insertions(+), 29 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 7c643c92e..7a62461dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -33,7 +33,7 @@ public BrandInfo update(Long brandId, String name, String description) { @Transactional public void delete(Long brandId) { brandService.delete(brandId); - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); } public Page getAll(Pageable pageable) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 18b5d04c9..4ceb32774 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -30,7 +30,7 @@ public ProductModel register(String name, String description, Money price, Long } @Transactional(readOnly = true) - public ProductModel getById(Long id) { + public ProductModel getProduct(Long id) { ProductModel product = productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. [id = " + id + "]")); if (product.getDeletedAt() != null) { @@ -39,6 +39,12 @@ public ProductModel getById(Long id) { return product; } + @Transactional(readOnly = true) + public ProductModel getProductForAdmin(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. [id = " + id + "]")); + } + @Transactional(readOnly = true) public Page getAll(Pageable pageable, ProductSortType sortType) { return productRepository.findAll(pageable, sortType); @@ -46,14 +52,14 @@ public Page getAll(Pageable pageable, ProductSortType sortType) { @Transactional public ProductModel update(Long id, String name, String description, Money price) { - ProductModel product = getById(id); + ProductModel product = findById(id); product.update(name, description, price); return product; } @Transactional public void delete(Long id) { - ProductModel product = getById(id); + ProductModel product = findById(id); product.delete(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java index d2dc834b4..4f4a1b59f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -1,6 +1,7 @@ package com.loopers.domain.product; public enum ProductSortType { + LATEST, CREATED_DESC, PRICE_ASC, PRICE_DESC, diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index a6a1b765a..d24e23f78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -51,14 +51,9 @@ public Page findAll(Pageable pageable, ProductSortType sortType) { return productJpaRepository.findAllByDeletedAtIsNull(sortedPageable); } - @Override - public ProductModel save(ProductModel product) { - return productJpaRepository.save(product); - } - private Sort toSort(ProductSortType sortType) { return switch (sortType) { - case CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); + case LATEST, CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "price.value"); case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index c16164ffa..26469edf0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -43,7 +43,7 @@ void deletesBrandAndCascadesProducts() { // then verify(brandService).delete(brandId); - verify(productService).deleteAllByBrandId(brandId); + verify(productService).softDeleteByBrandId(brandId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 697a0f91f..9040301bf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -43,12 +43,12 @@ void increasesLikeCountOnNewLike() { Long memberId = 1L; Long productId = 100L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); + given(productService.getProduct(productId)).willReturn(product); given(likeService.register(memberId, productId)).willReturn(true); // act likeFacade.register(memberId, productId); // assert - then(productService).should().increaseLikeCount(productId); + then(productService).should().incrementLikeCount(productId); } @DisplayName("이미 존재하면 좋아요 수를 증가시키지 않는다") @@ -58,12 +58,12 @@ void doesNotIncreaseLikeCountOnExistingLike() { Long memberId = 1L; Long productId = 100L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); + given(productService.getProduct(productId)).willReturn(product); given(likeService.register(memberId, productId)).willReturn(false); // act likeFacade.register(memberId, productId); // assert - then(productService).should(never()).increaseLikeCount(productId); + then(productService).should(never()).incrementLikeCount(productId); } } @@ -78,12 +78,12 @@ void decreasesLikeCountOnCancel() { Long memberId = 1L; Long productId = 100L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); + given(productService.getProduct(productId)).willReturn(product); given(likeService.cancel(memberId, productId)).willReturn(true); // act likeFacade.cancel(memberId, productId); // assert - then(productService).should().decreaseLikeCount(productId); + then(productService).should().decrementLikeCount(productId); } @DisplayName("좋아요가 없었으면 좋아요 수를 감소시키지 않는다") @@ -93,12 +93,12 @@ void doesNotDecreaseLikeCountWhenNotExists() { Long memberId = 1L; Long productId = 100L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); + given(productService.getProduct(productId)).willReturn(product); given(likeService.cancel(memberId, productId)).willReturn(false); // act likeFacade.cancel(memberId, productId); // assert - then(productService).should(never()).decreaseLikeCount(productId); + then(productService).should(never()).decrementLikeCount(productId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 8dd7a660b..827d086d4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -59,8 +59,8 @@ void createsOrderSuccessfully() { StockModel stock1 = new StockModel(1L, 100); StockModel stock2 = new StockModel(2L, 50); - given(productService.getById(1L)).willReturn(product1); - given(productService.getById(2L)).willReturn(product2); + given(productService.getProduct(1L)).willReturn(product1); + given(productService.getProduct(2L)).willReturn(product2); given(stockService.getByProductId(1L)).willReturn(stock1); given(stockService.getByProductId(2L)).willReturn(stock2); given(orderService.save(any(OrderModel.class))).willAnswer(invocation -> invocation.getArgument(0)); @@ -86,7 +86,7 @@ void throwsOnDeletedProduct() { ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product, "id", 1L); product.delete(); - given(productService.getById(1L)).willReturn(product); + given(productService.getProduct(1L)).willReturn(product); List commands = List.of(new OrderItemCommand(1L, 1)); // act @@ -107,7 +107,7 @@ void throwsOnInsufficientStock() { ReflectionTestUtils.setField(product, "id", 1L); StockModel stock = new StockModel(1L, 5); - given(productService.getById(1L)).willReturn(product); + given(productService.getProduct(1L)).willReturn(product); given(stockService.getByProductId(1L)).willReturn(stock); List commands = List.of(new OrderItemCommand(1L, 10)); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 93b7242f3..7f448b976 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -240,7 +240,7 @@ void softDeletesAllProducts() { createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); // when - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); // then Page result = productService.getProducts(brandId, ProductSortType.LATEST, PageRequest.of(0, 10)); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index 4187b82f9..da7cb5875 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -79,7 +79,7 @@ void returnsForExistingId() { ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - ProductModel result = productService.getById(id); + ProductModel result = productService.getProduct(id); // assert assertThat(result.getName()).isEqualTo("에어맥스"); } @@ -92,7 +92,7 @@ void throwsOnNonExistentId() { given(productRepository.findById(id)).willReturn(Optional.empty()); // act CoreException exception = assertThrows(CoreException.class, () -> { - productService.getById(id); + productService.getProduct(id); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -108,7 +108,7 @@ void throwsOnDeletedProduct() { given(productRepository.findById(id)).willReturn(Optional.of(product)); // act CoreException exception = assertThrows(CoreException.class, () -> { - productService.getById(id); + productService.getProduct(id); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -237,7 +237,7 @@ void increasesLikeCount() { ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.increaseLikeCount(id); + productService.incrementLikeCount(id); // assert assertThat(product.getLikeCount()).isEqualTo(1); } @@ -251,7 +251,7 @@ void decreasesLikeCount() { product.increaseLikeCount(); given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.decreaseLikeCount(id); + productService.decrementLikeCount(id); // assert assertThat(product.getLikeCount()).isEqualTo(0); } From 763b43377f3d29e28a3b22010cb44d46e5d49e87 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 13 Mar 2026 17:22:49 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductFacade에 @Cacheable/@CacheEvict 적용 (상품 상세 캐시) - LikeTransactionService에 좋아요 변경 시 캐시 무효화 추가 - RedisCacheConfig, CustomCacheErrorHandler 추가 - 고아 클래스 ProductListCacheService 삭제 - 중복 admin/ 패키지 및 interfaces/auth/ 패키지 정리 - import 경로 수정 및 getter 이름 불일치 해결 - 캐시 통합 테스트 추가 (ProductCacheIntegrationTest, LikeTransactionCacheTest) Co-Authored-By: Claude Opus 4.6 --- .http/cache-test.http | 25 +++ .../application/brand/BrandFacade.java | 2 +- .../loopers/application/brand/BrandInfo.java | 4 +- .../application/brand/BrandService.java | 25 +-- .../loopers/application/like/LikeFacade.java | 6 +- .../like/LikeTransactionService.java | 11 ++ .../application/order/OrderFacade.java | 6 +- .../application/product/ProductDetail.java | 12 +- .../application/product/ProductFacade.java | 50 ++++-- .../application/stock/StockService.java | 2 +- .../java/com/loopers/config/WebMvcConfig.java | 4 +- .../loopers/domain/brand/BrandRepository.java | 1 + .../com/loopers/domain/product/Money.java | 1 - .../domain/product/ProductRepository.java | 1 + .../loopers/domain/stock/StockRepository.java | 1 + .../brand/BrandJpaRepository.java | 6 +- .../product/ProductJpaRepository.java | 4 + .../product/ProductRepositoryImpl.java | 5 - .../stock/StockRepositoryImpl.java | 5 + .../api/auth/LoginMemberArgumentResolver.java | 2 +- .../api/brand/BrandAdminV1Controller.java | 2 +- .../brand/admin/BrandAdminV1Controller.java | 71 -------- .../api/brand/admin/BrandAdminV1Dto.java | 38 ----- .../api/coupon/CouponAdminV1Controller.java | 15 +- .../api/coupon/CouponV1Controller.java | 2 +- .../interfaces/api/like/LikeV1Controller.java | 2 +- .../api/member/MemberV1Controller.java | 2 +- .../api/order/OrderAdminV1Controller.java | 4 +- .../interfaces/api/order/OrderAdminV1Dto.java | 10 +- .../interfaces/api/order/OrderV1ApiSpec.java | 6 +- .../api/order/OrderV1Controller.java | 2 +- .../order/admin/OrderAdminV1Controller.java | 42 ----- .../api/order/admin/OrderAdminV1Dto.java | 61 ------- .../api/product/ProductAdminV1Controller.java | 20 +-- .../api/product/ProductAdminV1Dto.java | 12 +- .../api/product/ProductV1Controller.java | 20 +-- .../interfaces/api/product/ProductV1Dto.java | 22 ++- .../admin/ProductAdminV1Controller.java | 77 --------- .../api/product/admin/ProductAdminV1Dto.java | 52 ------ .../loopers/interfaces/auth/AdminInfo.java | 4 - .../loopers/interfaces/auth/AdminUser.java | 11 -- .../auth/AdminUserArgumentResolver.java | 38 ----- .../loopers/interfaces/auth/LoginMember.java | 11 -- .../auth/LoginMemberArgumentResolver.java | 39 ----- .../ConcurrencyIntegrationTest.java | 8 +- .../application/brand/BrandFacadeTest.java | 3 +- .../brand/BrandServiceIntegrationTest.java | 14 +- .../application/brand/BrandServiceTest.java | 21 ++- .../like/LikeFacadeIntegrationTest.java | 22 +-- .../application/like/LikeFacadeTest.java | 69 ++------ .../like/LikeTransactionCacheTest.java | 109 +++++++++++++ .../order/OrderFacadeIntegrationTest.java | 9 +- .../application/order/OrderFacadeTest.java | 54 +++---- .../product/ProductCacheIntegrationTest.java | 153 ++++++++++++++++++ .../product/ProductFacadeTest.java | 18 ++- .../ProductServiceIntegrationTest.java | 73 ++++----- .../product/ProductServiceTest.java | 26 ++- .../application/stock/StockServiceTest.java | 5 +- .../config/CustomCacheErrorHandlerTest.java | 66 ++++++++ .../loopers/config/RedisCacheConfigTest.java | 43 +++++ .../loopers/domain/like/LikeServiceTest.java | 77 +++------ .../domain/order/OrderServiceTest.java | 19 ++- .../api/brand/BrandV1ApiE2ETest.java | 2 +- .../interfaces/api/like/LikeV1ApiE2ETest.java | 6 +- .../api/order/OrderV1ApiE2ETest.java | 6 +- .../api/product/ProductV1ApiE2ETest.java | 14 +- .../config/redis/CustomCacheErrorHandler.java | 29 ++++ .../config/redis/RedisCacheConfig.java | 59 +++++++ 68 files changed, 818 insertions(+), 823 deletions(-) create mode 100644 .http/cache-test.http delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java create mode 100644 modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java create mode 100644 modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java diff --git a/.http/cache-test.http b/.http/cache-test.http new file mode 100644 index 000000000..d47829a43 --- /dev/null +++ b/.http/cache-test.http @@ -0,0 +1,25 @@ +### 캐시 미스 — 상품 상세 첫 번째 조회 +GET http://localhost:8080/api/v1/products/1 + +### 캐시 히트 — 상품 상세 두 번째 조회 (응답시간 비교) +GET http://localhost:8080/api/v1/products/1 + +### 상품 목록 — 캐시 미스 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 상품 목록 — 캐시 히트 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 상품 수정 (캐시 무효화 트리거) +PUT http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Admin-Id: admin + +{ + "name": "수정된 상품", + "description": "수정된 설명", + "price": 99999 +} + +### 수정 후 재조회 — 캐시 미스 (변경된 데이터) +GET http://localhost:8080/api/v1/products/1 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 7c643c92e..7a62461dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -33,7 +33,7 @@ public BrandInfo update(Long brandId, String name, String description) { @Transactional public void delete(Long brandId) { brandService.delete(brandId); - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); } public Page getAll(Pageable pageable) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java index 4a7db0896..8d7af7ca8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -16,8 +16,8 @@ public record BrandInfo( public static BrandInfo from(BrandModel model) { return new BrandInfo( model.getId(), - model.name().value(), - model.description(), + model.getName(), + model.getDescription(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 3fdc5c71d..897c0a6ba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,7 +1,6 @@ package com.loopers.application.brand; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -42,15 +41,14 @@ public BrandModel getBrandForAdmin(Long brandId) { @Transactional public BrandModel update(Long brandId, String name, String description) { BrandModel brand = findById(brandId); - BrandName newName = new BrandName(name); - if (!brand.name().equals(newName)) { + if (!brand.getName().equals(name)) { brandRepository.findByName(name).ifPresent(existing -> { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); }); } - brand.update(newName, description); + brand.update(name, description); return brand; } @@ -65,21 +63,8 @@ public Page getAll(Pageable pageable) { return brandRepository.findAll(pageable); } - @Transactional - public BrandModel update(Long id, String name, String description) { - BrandModel brand = getById(id); - brandRepository.findByName(name) - .filter(existing -> !existing.getId().equals(brand.getId())) - .ifPresent(existing -> { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다: " + name); - }); - brand.update(name, description); - return brand; - } - - @Transactional - public void delete(Long id) { - BrandModel brand = getById(id); - brand.delete(); + private BrandModel findById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다. [id = " + brandId + "]")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index bb5669f65..99ff72312 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -34,12 +34,12 @@ public Page getMyLikes(Long userId, Pageable pageable) { public Page getMyLikesWithProducts(Long userId, Pageable pageable) { Page likes = likeService.getMyLikes(userId, pageable); return likes.map(like -> { - ProductModel product = productService.getProduct(like.productId()); + ProductModel product = productService.getById(like.productId()); return new LikeWithProduct( like.getId(), product.getId(), - product.name(), - product.price().value(), + product.getName(), + product.getPrice().value(), like.getCreatedAt() ); }); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java index a3b5dd6c7..eecbfb7f6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java @@ -5,6 +5,7 @@ import com.loopers.domain.like.LikeToggleService; import com.loopers.application.product.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +18,7 @@ public class LikeTransactionService { private final LikeService likeService; private final ProductService productService; private final LikeToggleService likeToggleService; + private final CacheManager cacheManager; @Transactional public void doLike(Long userId, Long productId) { @@ -27,6 +29,7 @@ public void doLike(Long userId, Long productId) { if (result.countChanged()) { productService.incrementLikeCount(productId); + evictProductDetailCache(productId); } } @@ -37,5 +40,13 @@ public void doUnlike(Long userId, Long productId) { likeToggleService.unlike(activeLike.get()); productService.decrementLikeCount(activeLike.get().productId()); + evictProductDetailCache(productId); + } + + private void evictProductDetailCache(Long productId) { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.evict(productId); + } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 443496bdb..647b1648a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -43,11 +43,11 @@ public OrderResult placeOrder(Long userId, List commands, Long List snapshots = new ArrayList<>(); for (OrderItemCommand cmd : sorted) { - ProductModel product = productService.getProduct(cmd.productId()); - Money subtotal = product.price().multiply(cmd.quantity()); + ProductModel product = productService.getById(cmd.productId()); + Money subtotal = product.getPrice().multiply(cmd.quantity()); totalAmount = totalAmount.add(subtotal); snapshots.add(new SnapshotHolder( - product.getId(), product.name(), product.price(), cmd.quantity() + product.getId(), product.getName(), product.getPrice(), cmd.quantity() )); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java index d2dd60302..77a2dcee3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java @@ -22,18 +22,18 @@ public record ProductDetail( public static ProductDetail ofCustomer(ProductModel product, String brandName, StockStatus stockStatus) { return new ProductDetail( - product.getId(), product.name(), product.description(), - product.price().value(), product.brandId(), brandName, - product.likeCount(), stockStatus, 0, + product.getId(), product.getName(), product.getDescription(), + product.getPrice().value(), product.getBrandId(), brandName, + product.getLikeCount(), stockStatus, 0, product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() ); } public static ProductDetail ofAdmin(ProductModel product, String brandName, int stockQuantity) { return new ProductDetail( - product.getId(), product.name(), product.description(), - product.price().value(), product.brandId(), brandName, - product.likeCount(), null, stockQuantity, + product.getId(), product.getName(), product.getDescription(), + product.getPrice().value(), product.getBrandId(), brandName, + product.getLikeCount(), null, stockQuantity, product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 0b8eb307b..93ec38bd8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,8 +6,11 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockStatus; import com.loopers.application.stock.StockService; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -22,63 +25,76 @@ public class ProductFacade { private final StockService stockService; @Transactional - public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + public ProductDetail register(String name, String description, Money price, Long brandId, int initialStock) { brandService.getBrand(brandId); ProductModel product = productService.register(name, description, price, brandId); - stockService.create(product.getId(), initialStock); - return product; + stockService.save(product.getId(), initialStock); + String brandName = getBrandName(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofAdmin(product, brandName, stock.getQuantity()); } + @Cacheable(cacheNames = "productDetail", key = "#productId") @Transactional(readOnly = true) public ProductDetail getProduct(Long productId) { - ProductModel product = productService.getProduct(productId); - String brandName = getBrandName(product.brandId()); + ProductModel product = productService.getById(productId); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(productId); - return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity())); } @Transactional(readOnly = true) public ProductDetail getProductForAdmin(Long productId) { - ProductModel product = productService.getProductForAdmin(productId); - String brandName = getBrandName(product.brandId()); + ProductModel product = productService.getById(productId); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(productId); - return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + return ProductDetail.ofAdmin(product, brandName, stock.getQuantity()); } @Transactional(readOnly = true) public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { - Page products = productService.getProducts(brandId, sortType, pageable); + Page products = productService.getAll(pageable, sortType); return products.map(product -> { - String brandName = getBrandName(product.brandId()); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity())); }); } @Transactional(readOnly = true) - public Page getProductsForAdmin(Long brandId, Pageable pageable) { - Page products = productService.getProductsForAdmin(brandId, pageable); + public Page getProductsForAdmin(Pageable pageable, ProductSortType sortType) { + Page products = productService.getAll(pageable, sortType); return products.map(product -> { - String brandName = getBrandName(product.brandId()); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + return ProductDetail.ofAdmin(product, brandName, stock.getQuantity()); }); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") @Transactional public ProductDetail update(Long productId, String name, String description, Money price) { productService.update(productId, name, description, price); return getProductForAdmin(productId); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") public void delete(Long productId) { productService.delete(productId); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") + @Transactional + public ProductDetail updateStock(Long productId, int quantity) { + StockModel stock = stockService.getByProductId(productId); + stock.update(quantity); + return getProductForAdmin(productId); + } + private String getBrandName(Long brandId) { try { BrandModel brand = brandService.getBrandForAdmin(brandId); - return brand.name().value(); + return brand.getName(); } catch (Exception e) { return null; } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java index c9056c2d5..125d3ece7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java @@ -38,6 +38,6 @@ public StockModel getByProductIdForUpdate(Long productId) { public Map getByProductIds(List productIds) { return stockRepository.findAllByProductIdIn(productIds) .stream() - .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + .collect(Collectors.toMap(StockModel::getProductId, stock -> stock)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index 6e4cc110b..32bc4edca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -1,7 +1,7 @@ package com.loopers.config; -import com.loopers.interfaces.auth.AdminUserArgumentResolver; -import com.loopers.interfaces.auth.LoginMemberArgumentResolver; +import com.loopers.interfaces.api.auth.AdminUserArgumentResolver; +import com.loopers.interfaces.api.auth.LoginMemberArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 9f8204caa..e1565c888 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -7,6 +7,7 @@ import java.util.Optional; public interface BrandRepository { + BrandModel save(BrandModel brand); Optional findById(Long id); Optional findByName(String name); Page findAll(Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java index e64ce0ffd..5c1d08145 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -14,7 +14,6 @@ public class Money { public static final Money ZERO = new Money(0); - @Column(name = "price", nullable = false) private int value; public Money(int value) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 2c0eeecfc..73f8cabf6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -16,6 +16,7 @@ public interface ProductRepository { Optional findById(Long id); List findAllByBrandId(Long brandId); + Page findAll(Pageable pageable, ProductSortType sortType); List findAllByIdInAndDeletedAtIsNull(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java index e0c62cf0d..5e3761cee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -4,6 +4,7 @@ import java.util.Optional; public interface StockRepository { + StockModel save(StockModel stock); Optional findByProductId(Long productId); Optional findByProductIdForUpdate(Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index e4a3b1a3c..cdb8d3eea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -10,7 +10,11 @@ public interface BrandJpaRepository extends JpaRepository { - Optional findByNameValue(String value); + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByNameAndDeletedAtIsNull(String name); + + Page findAllByDeletedAtIsNull(Pageable pageable); List findAllByIdIn(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 9d09cdbc4..9480f9ab4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -21,8 +21,12 @@ public interface ProductJpaRepository extends JpaRepository @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") int decrementLikeCount(@Param("id") Long id); + Optional findByIdAndDeletedAtIsNull(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); Page findAllByBrandId(Long brandId, Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index a6a1b765a..641660565 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -51,11 +51,6 @@ public Page findAll(Pageable pageable, ProductSortType sortType) { return productJpaRepository.findAllByDeletedAtIsNull(sortedPageable); } - @Override - public ProductModel save(ProductModel product) { - return productJpaRepository.save(product); - } - private Sort toSort(ProductSortType sortType) { return switch (sortType) { case CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java index d7242d0c6..d95eb90c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -14,6 +14,11 @@ public class StockRepositoryImpl implements StockRepository { private final StockJpaRepository stockJpaRepository; + @Override + public StockModel save(StockModel stock) { + return stockJpaRepository.save(stock); + } + @Override public Optional findByProductId(Long productId) { return stockJpaRepository.findByProductId(productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java index d746e275d..c686dcac0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.auth; -import com.loopers.domain.member.MemberAuthService; +import com.loopers.application.member.MemberAuthService; import com.loopers.domain.member.MemberModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 07d6d8821..e3f7560e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -50,7 +50,7 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "brandId") Long brandId ) { - BrandInfo info = brandFacade.getById(brandId); + BrandInfo info = brandFacade.getBrandForAdmin(brandId); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java deleted file mode 100644 index 680103f80..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.loopers.interfaces.api.brand.admin; - -import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.BrandInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/brands") -public class BrandAdminV1Controller { - - private final BrandFacade brandFacade; - - @PostMapping - public ApiResponse create( - @AdminUser AdminInfo admin, - @RequestBody BrandAdminV1Dto.CreateRequest request - ) { - BrandInfo info = brandFacade.register(request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Page result = brandFacade.getAll(PageRequest.of(page, size)); - return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); - } - - @GetMapping("/{brandId}") - public ApiResponse getBrand( - @AdminUser AdminInfo admin, - @PathVariable Long brandId - ) { - BrandInfo info = brandFacade.getBrandForAdmin(brandId); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @PutMapping("/{brandId}") - public ApiResponse update( - @AdminUser AdminInfo admin, - @PathVariable Long brandId, - @RequestBody BrandAdminV1Dto.UpdateRequest request - ) { - BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @DeleteMapping("/{brandId}") - public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { - brandFacade.delete(brandId); - return ApiResponse.success(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java deleted file mode 100644 index 7ae7e03fe..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.interfaces.api.brand.admin; - -import com.loopers.application.brand.BrandInfo; - -import java.time.ZonedDateTime; - -public class BrandAdminV1Dto { - - public record CreateRequest( - String name, - String description - ) {} - - public record UpdateRequest( - String name, - String description - ) {} - - public record BrandResponse( - Long id, - String name, - String description, - ZonedDateTime createdAt, - ZonedDateTime updatedAt, - ZonedDateTime deletedAt - ) { - public static BrandResponse from(BrandInfo info) { - return new BrandResponse( - info.id(), - info.name(), - info.description(), - info.createdAt(), - info.updatedAt(), - info.deletedAt() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java index ea9294343..12263dce7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -4,8 +4,7 @@ import com.loopers.application.coupon.CouponService; import com.loopers.domain.coupon.CouponModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; +import com.loopers.interfaces.api.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,7 +19,7 @@ public class CouponAdminV1Controller { @GetMapping("/api-admin/v1/coupons") public ApiResponse> getCoupons( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, Pageable pageable ) { Page coupons = couponService.getAllCoupons(pageable); @@ -29,7 +28,7 @@ public ApiResponse> getCoupons( @GetMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse getCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId ) { CouponModel coupon = couponService.getCoupon(couponId); @@ -38,7 +37,7 @@ public ApiResponse getCoupon( @PostMapping("/api-admin/v1/coupons") public ApiResponse createCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @RequestBody CouponAdminV1Dto.CreateRequest request ) { CouponModel coupon = couponService.create( @@ -50,7 +49,7 @@ public ApiResponse createCoupon( @PutMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse updateCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId, @RequestBody CouponAdminV1Dto.UpdateRequest request ) { @@ -63,7 +62,7 @@ public ApiResponse updateCoupon( @DeleteMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse deleteCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId ) { couponService.delete(couponId); @@ -72,7 +71,7 @@ public ApiResponse deleteCoupon( @GetMapping("/api-admin/v1/coupons/{couponId}/issues") public ApiResponse> getCouponIssues( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId, Pageable pageable ) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index cbbe0042e..77e2c240c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -6,7 +6,7 @@ import com.loopers.domain.coupon.CouponModel; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index da90d2f46..cccf79cff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -4,7 +4,7 @@ import com.loopers.application.like.LikeWithProduct; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index bcdcfd412..93235c45a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -4,7 +4,7 @@ import com.loopers.application.member.MemberInfo; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java index fcfcc414a..9c389b0c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -25,7 +25,7 @@ public ApiResponse> getAll( @AdminUser String adminLdap, Pageable pageable ) { - Page response = orderFacade.getAllOrders(pageable) + Page response = orderFacade.getAllForAdmin(pageable) .map(OrderAdminV1Dto.OrderAdminSummaryResponse::from); return ApiResponse.success(response); } @@ -36,7 +36,7 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "orderId") Long orderId ) { - OrderInfo.Detail info = orderFacade.getDetailForAdmin(orderId); + OrderInfo info = orderFacade.getOrderForAdmin(orderId); return ApiResponse.success(OrderAdminV1Dto.OrderAdminDetailResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java index a77501ea1..63dce7090 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -10,9 +10,9 @@ public class OrderAdminV1Dto { public record OrderAdminSummaryResponse( Long id, int totalAmount, String status, int itemCount, ZonedDateTime createdAt ) { - public static OrderAdminSummaryResponse from(OrderInfo.Summary info) { + public static OrderAdminSummaryResponse from(OrderInfo info) { return new OrderAdminSummaryResponse( - info.id(), info.totalAmount(), info.status(), info.itemCount(), info.createdAt() + info.orderId(), info.totalAmount(), info.status(), info.items().size(), info.createdAt() ); } } @@ -21,12 +21,12 @@ public record OrderAdminDetailResponse( Long id, Long memberId, int totalAmount, String status, List orderItems, ZonedDateTime createdAt ) { - public static OrderAdminDetailResponse from(OrderInfo.Detail info) { - List items = info.orderItems().stream() + public static OrderAdminDetailResponse from(OrderInfo info) { + List items = info.items().stream() .map(OrderV1Dto.OrderItemResponse::from) .toList(); return new OrderAdminDetailResponse( - info.id(), info.memberId(), info.totalAmount(), info.status(), items, info.createdAt() + info.orderId(), info.userId(), info.totalAmount(), info.status(), items, info.createdAt() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java index 21c758e17..b81ac1ccb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -11,11 +11,11 @@ public interface OrderV1ApiSpec { @Operation(summary = "주문 생성", description = "새로운 주문을 생성합니다.") - ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateOrderRequest request); + ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateRequest request); @Operation(summary = "내 주문 목록 조회", description = "내 주문 목록을 조회합니다.") - ApiResponse> getMyOrders(MemberModel member, Pageable pageable); + ApiResponse getMyOrders(MemberModel member, Pageable pageable); @Operation(summary = "주문 상세 조회", description = "주문 상세 정보를 조회합니다.") - ApiResponse getById(MemberModel member, Long orderId); + ApiResponse getById(MemberModel member, Long orderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 385550781..ac1c863ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -5,7 +5,7 @@ import com.loopers.application.order.OrderResult; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java deleted file mode 100644 index 1067c8535..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.interfaces.api.order.admin; - -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/orders") -public class OrderAdminV1Controller { - - private final OrderFacade orderFacade; - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Page orders = orderFacade.getAllForAdmin(PageRequest.of(page, size)); - return ApiResponse.success(orders.map(OrderAdminV1Dto.OrderSummaryResponse::from)); - } - - @GetMapping("/{orderId}") - public ApiResponse getOrder( - @AdminUser AdminInfo admin, - @PathVariable Long orderId - ) { - OrderInfo info = orderFacade.getOrderForAdmin(orderId); - return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java deleted file mode 100644 index a7c275e62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.loopers.interfaces.api.order.admin; - -import com.loopers.application.order.OrderInfo; - -import java.time.ZonedDateTime; -import java.util.List; - -public class OrderAdminV1Dto { - - public record OrderResponse( - Long orderId, - Long userId, - String status, - int totalAmount, - List items, - ZonedDateTime createdAt - ) { - - public static OrderResponse from(OrderInfo info) { - List items = info.items().stream() - .map(OrderItemResponse::from) - .toList(); - return new OrderResponse( - info.orderId(), info.userId(), info.status(), - info.totalAmount(), items, info.createdAt() - ); - } - } - - public record OrderSummaryResponse( - Long orderId, - Long userId, - String status, - int totalAmount, - ZonedDateTime createdAt - ) { - - public static OrderSummaryResponse from(OrderInfo info) { - return new OrderSummaryResponse( - info.orderId(), info.userId(), info.status(), - info.totalAmount(), info.createdAt() - ); - } - } - - public record OrderItemResponse( - Long productId, - String productName, - int productPrice, - int quantity, - int subtotal - ) { - - public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { - return new OrderItemResponse( - item.productId(), item.productName(), - item.productPrice(), item.quantity(), item.subtotal() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index c348c5076..fecc922ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.product.ProductDetail; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; @@ -33,11 +33,11 @@ public ApiResponse create( @AdminUser String adminLdap, @RequestBody ProductAdminV1Dto.CreateRequest request ) { - ProductInfo.AdminDetail info = productFacade.register( + ProductDetail detail = productFacade.register( request.name(), request.description(), new Money(request.price()), request.brandId(), request.stockQuantity() ); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @GetMapping @@ -48,7 +48,7 @@ public ApiResponse> getAll( @RequestParam(value = "sortType", defaultValue = "CREATED_DESC") String sortType ) { ProductSortType sort = ProductSortType.valueOf(sortType); - Page response = productFacade.getAllForAdmin(pageable, sort) + Page response = productFacade.getProductsForAdmin(pageable, sort) .map(ProductAdminV1Dto.ProductAdminSummaryResponse::from); return ApiResponse.success(response); } @@ -59,8 +59,8 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "productId") Long productId ) { - ProductInfo.AdminDetail info = productFacade.getDetailForAdmin(productId); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @PutMapping("/{productId}") @@ -70,10 +70,10 @@ public ApiResponse update( @PathVariable(value = "productId") Long productId, @RequestBody ProductAdminV1Dto.UpdateRequest request ) { - ProductInfo.AdminDetail info = productFacade.update( + ProductDetail detail = productFacade.update( productId, request.name(), request.description(), new Money(request.price()) ); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @DeleteMapping("/{productId}") @@ -93,7 +93,7 @@ public ApiResponse updateStock( @PathVariable(value = "productId") Long productId, @RequestBody ProductAdminV1Dto.UpdateStockRequest request ) { - ProductInfo.AdminDetail info = productFacade.updateStock(productId, request.quantity()); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + ProductDetail detail = productFacade.updateStock(productId, request.quantity()); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java index 2abdd5f52..c47d97e31 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductDetail; public class ProductAdminV1Dto { @@ -13,9 +13,9 @@ public record UpdateStockRequest(int quantity) {} public record ProductAdminSummaryResponse( Long id, String name, int price, String brandName, int stockQuantity ) { - public static ProductAdminSummaryResponse from(ProductInfo.AdminSummary info) { + public static ProductAdminSummaryResponse from(ProductDetail detail) { return new ProductAdminSummaryResponse( - info.id(), info.name(), info.price(), info.brandName(), info.stockQuantity() + detail.id(), detail.name(), detail.price(), detail.brandName(), detail.stockQuantity() ); } } @@ -24,10 +24,10 @@ public record ProductAdminDetailResponse( Long id, String name, String description, int price, Long brandId, String brandName, int likeCount, int stockQuantity ) { - public static ProductAdminDetailResponse from(ProductInfo.AdminDetail info) { + public static ProductAdminDetailResponse from(ProductDetail detail) { return new ProductAdminDetailResponse( - info.id(), info.name(), info.description(), info.price(), - info.brandId(), info.brandName(), info.likeCount(), info.stockQuantity() + detail.id(), detail.name(), detail.description(), detail.price(), + detail.brandId(), detail.brandName(), detail.likeCount(), detail.stockQuantity() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 71e87e49d..160cf4525 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -6,6 +6,7 @@ import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -21,19 +22,20 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductFacade productFacade; @GetMapping - public ApiResponse> getProducts( - @RequestParam(required = false) Long brandId, - @RequestParam(defaultValue = "LATEST") ProductSortType sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @Override + public ApiResponse> getAll( + Pageable pageable, + @RequestParam(defaultValue = "CREATED_DESC") String sortType ) { - Page products = productFacade.getProducts(brandId, sort, PageRequest.of(page, size)); - return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); + ProductSortType sort = ProductSortType.valueOf(sortType); + Page products = productFacade.getProducts(null, sort, pageable); + return ApiResponse.success(products.map(ProductV1Dto.ProductSummaryResponse::from)); } @GetMapping("/{productId}") - public ApiResponse getProduct(@PathVariable Long productId) { + @Override + public ApiResponse getById(@PathVariable Long productId) { ProductDetail detail = productFacade.getProduct(productId); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(detail)); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index b0a96c407..973da7ac8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,15 +1,29 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductDetail; -import com.loopers.domain.stock.StockStatus; public class ProductV1Dto { public record ProductSummaryResponse( Long id, String name, int price, String brandName, String stockStatus ) { - public static ProductResponse from(ProductDetail detail) { - return new ProductResponse( + public static ProductSummaryResponse from(ProductDetail detail) { + return new ProductSummaryResponse( + detail.id(), + detail.name(), + detail.price(), + detail.brandName(), + detail.stockStatus() != null ? detail.stockStatus().name() : null + ); + } + } + + public record ProductDetailResponse( + Long id, String name, String description, int price, + Long brandId, String brandName, int likeCount, String stockStatus + ) { + public static ProductDetailResponse from(ProductDetail detail) { + return new ProductDetailResponse( detail.id(), detail.name(), detail.description(), @@ -17,7 +31,7 @@ public static ProductResponse from(ProductDetail detail) { detail.brandId(), detail.brandName(), detail.likeCount(), - detail.stockStatus() + detail.stockStatus() != null ? detail.stockStatus().name() : null ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java deleted file mode 100644 index 341540a36..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.loopers.interfaces.api.product.admin; - -import com.loopers.application.product.ProductDetail; -import com.loopers.application.product.ProductFacade; -import com.loopers.domain.product.Money; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/products") -public class ProductAdminV1Controller { - - private final ProductFacade productFacade; - - @PostMapping - public ApiResponse create( - @AdminUser AdminInfo admin, - @RequestBody ProductAdminV1Dto.CreateRequest request - ) { - var product = productFacade.register( - request.name(), request.description(), new Money(request.price()), - request.brandId(), request.initialStock() - ); - ProductDetail detail = productFacade.getProductForAdmin(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) Long brandId - ) { - Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); - return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); - } - - @GetMapping("/{productId}") - public ApiResponse getProduct( - @AdminUser AdminInfo admin, - @PathVariable Long productId - ) { - ProductDetail detail = productFacade.getProductForAdmin(productId); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @PutMapping("/{productId}") - public ApiResponse update( - @AdminUser AdminInfo admin, - @PathVariable Long productId, - @RequestBody ProductAdminV1Dto.UpdateRequest request - ) { - ProductDetail detail = productFacade.update(productId, request.name(), request.description(), new Money(request.price())); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @DeleteMapping("/{productId}") - public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { - productFacade.delete(productId); - return ApiResponse.success(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java deleted file mode 100644 index 5f58c5cb2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.loopers.interfaces.api.product.admin; - -import com.loopers.application.product.ProductDetail; - -import java.time.ZonedDateTime; - -public class ProductAdminV1Dto { - - public record CreateRequest( - String name, - String description, - int price, - Long brandId, - int initialStock - ) {} - - public record UpdateRequest( - String name, - String description, - int price - ) {} - - public record ProductResponse( - Long id, - String name, - String description, - int price, - Long brandId, - String brandName, - int likeCount, - int stockQuantity, - ZonedDateTime createdAt, - ZonedDateTime updatedAt, - ZonedDateTime deletedAt - ) { - public static ProductResponse from(ProductDetail detail) { - return new ProductResponse( - detail.id(), - detail.name(), - detail.description(), - detail.price(), - detail.brandId(), - detail.brandName(), - detail.likeCount(), - detail.stockQuantity(), - detail.createdAt(), - detail.updatedAt(), - detail.deletedAt() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java deleted file mode 100644 index 516f8b1d8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.loopers.interfaces.auth; - -public record AdminInfo(String ldap) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java deleted file mode 100644 index 3cf3df48e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.interfaces.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface AdminUser { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java deleted file mode 100644 index 969bd5d7e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.interfaces.auth; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.springframework.core.MethodParameter; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -@Component -public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { - - private static final String VALID_LDAP = "loopers.admin"; - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(AdminUser.class) - && AdminInfo.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - String ldap = webRequest.getHeader("X-Loopers-Ldap"); - - if (ldap == null || ldap.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "어드민 인증 헤더가 누락되었습니다."); - } - - if (!VALID_LDAP.equals(ldap)) { - throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 어드민 인증입니다."); - } - - return new AdminInfo(ldap); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java deleted file mode 100644 index 93ea3e09a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.interfaces.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface LoginMember { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java deleted file mode 100644 index 7b8ccac5e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.interfaces.auth; - -import com.loopers.application.member.MemberAuthService; -import com.loopers.domain.member.MemberModel; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.core.MethodParameter; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -@RequiredArgsConstructor -@Component -public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { - - private final MemberAuthService memberAuthService; - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(LoginMember.class) - && MemberModel.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - String loginId = webRequest.getHeader("X-Loopers-LoginId"); - String loginPw = webRequest.getHeader("X-Loopers-LoginPw"); - - if (loginId == null || loginPw == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "인증 헤더가 누락되었습니다."); - } - - return memberAuthService.authenticate(loginId, loginPw); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java index d2543dca5..f983bbdd9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java @@ -48,7 +48,7 @@ class ConcurrencyIntegrationTest { private Long createBrand() { return brandService.register("테스트브랜드", "설명").getId(); } private Long createProduct(Long brandId, int stock) { - return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).getId(); + return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).id(); } @DisplayName("재고 동시성") @@ -86,7 +86,7 @@ void stockDecreasedCorrectlyUnderConcurrency() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(10); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(90); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(90); } @DisplayName("재고 5개인 상품에 10명이 동시 주문하면 5명만 성공한다") @@ -120,7 +120,7 @@ void onlyAvailableStockSucceeds() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(5); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(0); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(0); } } @@ -199,7 +199,7 @@ void likeCountAccurateUnderConcurrency() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(10); - assertThat(productService.getProduct(productId).likeCount()).isEqualTo(10); + assertThat(productService.getById(productId).getLikeCount()).isEqualTo(10); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index c16164ffa..b38142344 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -1,6 +1,5 @@ package com.loopers.application.brand; -import com.loopers.application.brand.BrandService; import com.loopers.application.product.ProductService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -43,7 +42,7 @@ void deletesBrandAndCascadesProducts() { // then verify(brandService).delete(brandId); - verify(productService).deleteAllByBrandId(brandId); + verify(productService).softDeleteByBrandId(brandId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index dca5344fd..123462392 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -44,8 +44,8 @@ void createsBrandSuccessfully() { // then assertAll( () -> assertThat(result.getId()).isNotNull(), - () -> assertThat(result.name().value()).isEqualTo("나이키"), - () -> assertThat(result.description()).isEqualTo("스포츠 브랜드") + () -> assertThat(result.getName()).isEqualTo("나이키"), + () -> assertThat(result.getDescription()).isEqualTo("스포츠 브랜드") ); } @@ -78,7 +78,7 @@ void returnsBrand() { BrandModel result = brandService.getBrand(saved.getId()); // then - assertThat(result.name().value()).isEqualTo("나이키"); + assertThat(result.getName()).isEqualTo("나이키"); } @DisplayName("삭제된 브랜드면 NOT_FOUND 예외가 발생한다") @@ -112,8 +112,8 @@ void updatesSuccessfully() { // then assertAll( - () -> assertThat(result.name().value()).isEqualTo("뉴발란스"), - () -> assertThat(result.description()).isEqualTo("라이프스타일 브랜드") + () -> assertThat(result.getName()).isEqualTo("뉴발란스"), + () -> assertThat(result.getDescription()).isEqualTo("라이프스타일 브랜드") ); } @@ -128,8 +128,8 @@ void skipsDuplicateCheckWhenSameName() { // then assertAll( - () -> assertThat(result.name().value()).isEqualTo("나이키"), - () -> assertThat(result.description()).isEqualTo("설명만 변경") + () -> assertThat(result.getName()).isEqualTo("나이키"), + () -> assertThat(result.getDescription()).isEqualTo("설명만 변경") ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java index 2ff3c8645..90388c5b3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -1,20 +1,24 @@ package com.loopers.application.brand; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -24,6 +28,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class BrandServiceTest { @@ -79,7 +84,7 @@ void throwsOnDuplicateName() { @DisplayName("브랜드 조회") @Nested - class GetById { + class GetBrand { @DisplayName("존재하는 ID면 브랜드를 반환한다") @Test @@ -89,7 +94,7 @@ void returnsForExistingId() { BrandModel brand = new BrandModel("나이키", "스포츠"); given(brandRepository.findById(id)).willReturn(Optional.of(brand)); // act - BrandModel result = brandService.getById(id); + BrandModel result = brandService.getBrand(id); // assert assertThat(result.getName()).isEqualTo("나이키"); } @@ -102,7 +107,7 @@ void throwsOnNonExistentId() { given(brandRepository.findById(id)).willReturn(Optional.empty()); // act CoreException exception = assertThrows(CoreException.class, () -> { - brandService.getById(id); + brandService.getBrand(id); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -157,7 +162,7 @@ class Delete { void softDeletesSuccessfully() { // given Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); // when @@ -193,8 +198,8 @@ void returnsPagedResult() { // given Pageable pageable = PageRequest.of(0, 10); List brands = List.of( - new BrandModel(new BrandName("나이키"), "스포츠"), - new BrandModel(new BrandName("아디다스"), "스포츠") + new BrandModel("나이키", "스포츠"), + new BrandModel("아디다스", "스포츠") ); Page page = new PageImpl<>(brands, pageable, brands.size()); when(brandRepository.findAll(pageable)).thenReturn(page); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index afee36977..11cc2383b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -34,7 +34,7 @@ class LikeFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } private Long createProduct(String name, int price, Long brandId) { - return productFacade.register(name, "설명", new Money(price), brandId, 10).getId(); + return productFacade.register(name, "설명", new Money(price), brandId, 10).id(); } @DisplayName("좋아요 등록") @@ -52,8 +52,8 @@ void likesProductAndIncrementsCount() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } @DisplayName("같은 상품에 두 번 좋아요해도 likeCount는 1이다 (멱등성)") @@ -68,8 +68,8 @@ void likeIsIdempotent() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } @DisplayName("좋아요 취소 후 다시 좋아요하면 복원된다") @@ -85,8 +85,8 @@ void restoresAfterUnlike() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } } @@ -106,8 +106,8 @@ void unlikeDecrementsCount() { likeFacade.unlike(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(0); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(0); } @DisplayName("좋아요하지 않은 상품을 취소해도 예외가 발생하지 않는다 (멱등성)") @@ -133,8 +133,8 @@ void doubleUnlikeIsIdempotent() { // when & then — 예외 없이 정상 완료 likeFacade.unlike(1L, productId); - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(0); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 697a0f91f..1b5e6e608 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -1,10 +1,8 @@ package com.loopers.application.like; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -13,9 +11,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class LikeFacadeTest { @@ -30,75 +27,39 @@ class LikeFacadeTest { private ProductService productService; @Mock - private BrandService brandService; + private LikeTransactionService likeTransactionService; @DisplayName("좋아요 등록") @Nested - class Register { + class Like { - @DisplayName("새로 생성되면 상품 좋아요 수를 증가시킨다") + @DisplayName("like 호출 시 likeTransactionService.doLike를 위임한다") @Test - void increasesLikeCountOnNewLike() { + void delegatesToLikeTransactionService() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.register(memberId, productId)).willReturn(true); // act - likeFacade.register(memberId, productId); + likeFacade.like(userId, productId); // assert - then(productService).should().increaseLikeCount(productId); - } - - @DisplayName("이미 존재하면 좋아요 수를 증가시키지 않는다") - @Test - void doesNotIncreaseLikeCountOnExistingLike() { - // arrange - Long memberId = 1L; - Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.register(memberId, productId)).willReturn(false); - // act - likeFacade.register(memberId, productId); - // assert - then(productService).should(never()).increaseLikeCount(productId); + verify(likeTransactionService).doLike(userId, productId); } } @DisplayName("좋아요 취소") @Nested - class Cancel { - - @DisplayName("취소되면 상품 좋아요 수를 감소시킨다") - @Test - void decreasesLikeCountOnCancel() { - // arrange - Long memberId = 1L; - Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.cancel(memberId, productId)).willReturn(true); - // act - likeFacade.cancel(memberId, productId); - // assert - then(productService).should().decreaseLikeCount(productId); - } + class Unlike { - @DisplayName("좋아요가 없었으면 좋아요 수를 감소시키지 않는다") + @DisplayName("unlike 호출 시 likeTransactionService.doUnlike를 위임한다") @Test - void doesNotDecreaseLikeCountWhenNotExists() { + void delegatesToLikeTransactionService() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.cancel(memberId, productId)).willReturn(false); // act - likeFacade.cancel(memberId, productId); + likeFacade.unlike(userId, productId); // assert - then(productService).should(never()).decreaseLikeCount(productId); + verify(likeTransactionService).doUnlike(userId, productId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java new file mode 100644 index 000000000..ed6369b60 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java @@ -0,0 +1,109 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class LikeTransactionCacheTest { + + static final GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + + static { + redisContainer.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + String host = redisContainer.getHost(); + int port = redisContainer.getFirstMappedPort(); + registry.add("datasource.redis.database", () -> 0); + registry.add("datasource.redis.master.host", () -> host); + registry.add("datasource.redis.master.port", () -> port); + registry.add("datasource.redis.replicas[0].host", () -> host); + registry.add("datasource.redis.replicas[0].port", () -> port); + } + + @Autowired private LikeTransactionService likeTransactionService; + @Autowired private ProductFacade productFacade; + @Autowired private CacheManager cacheManager; + @Autowired private BrandJpaRepository brandJpaRepository; + @Autowired private ProductJpaRepository productJpaRepository; + @Autowired private StockJpaRepository stockJpaRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.clear(); + } + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("좋아요 등록 시 상품 상세 캐시가 삭제된다") + void 좋아요_등록_시_상품_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + likeTransactionService.doLike(1L, product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("좋아요 취소 시 상품 상세 캐시가 삭제된다") + void 좋아요_취소_시_상품_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + likeTransactionService.doLike(1L, product.getId()); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + likeTransactionService.doUnlike(1L, product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 977759387..e8a943555 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -3,7 +3,6 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; -import com.loopers.application.order.OrderService; import com.loopers.domain.order.OrderStatus; import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; @@ -41,7 +40,7 @@ class OrderFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } private Long createProduct(String name, int price, Long brandId, int stock) { - return productFacade.register(name, "설명", new Money(price), brandId, stock).getId(); + return productFacade.register(name, "설명", new Money(price), brandId, stock).id(); } @DisplayName("주문 생성") @@ -84,7 +83,7 @@ void deductsStock() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3)), null); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(7); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(7); } @DisplayName("재고 부족 시 BAD_REQUEST 예외가 발생한다") @@ -105,8 +104,8 @@ void rollsBackOnPartialFailure() { Long p2 = createProduct("조던", 159000, brandId, 1); assertThrows(CoreException.class, () -> orderFacade.placeOrder(1L, List.of( new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)), null)); - assertThat(stockService.getByProductId(p1).quantity()).isEqualTo(10); - assertThat(stockService.getByProductId(p2).quantity()).isEqualTo(1); + assertThat(stockService.getByProductId(p1).getQuantity()).isEqualTo(10); + assertThat(stockService.getByProductId(p2).getQuantity()).isEqualTo(1); } @DisplayName("삭제된 상품 주문 시 NOT_FOUND 예외가 발생한다") diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 8dd7a660b..8d13eefa1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -1,12 +1,13 @@ package com.loopers.application.order; +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -43,15 +44,21 @@ class OrderFacadeTest { @Mock private StockService stockService; + @Mock + private CouponIssueService couponIssueService; + + @Mock + private CouponService couponService; + @DisplayName("주문 생성") @Nested - class CreateOrder { + class PlaceOrder { @DisplayName("정상적으로 주문을 생성한다") @Test void createsOrderSuccessfully() { // arrange - Long memberId = 1L; + Long userId = 1L; ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product1, "id", 1L); ProductModel product2 = new ProductModel("에어포스", "캐주얼", new Money(109000), 1L); @@ -61,59 +68,40 @@ void createsOrderSuccessfully() { given(productService.getById(1L)).willReturn(product1); given(productService.getById(2L)).willReturn(product2); - given(stockService.getByProductId(1L)).willReturn(stock1); - given(stockService.getByProductId(2L)).willReturn(stock2); + given(stockService.getByProductIdForUpdate(1L)).willReturn(stock1); + given(stockService.getByProductIdForUpdate(2L)).willReturn(stock2); given(orderService.save(any(OrderModel.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(orderService.saveAllItems(any())).willAnswer(invocation -> invocation.getArgument(0)); List commands = List.of( new OrderItemCommand(1L, 2), new OrderItemCommand(2L, 1) ); // act - OrderInfo.Detail result = orderFacade.createOrder(memberId, commands); + OrderResult result = orderFacade.placeOrder(userId, commands, null); // assert assertAll( - () -> assertThat(result.totalAmount()).isEqualTo(129000 * 2 + 109000), - () -> assertThat(result.orderItems()).hasSize(2) + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(129000 * 2 + 109000)), + () -> assertThat(result.items()).hasSize(2) ); } - @DisplayName("삭제된 상품이 포함되면 NOT_FOUND 예외가 발생한다") - @Test - void throwsOnDeletedProduct() { - // arrange - Long memberId = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - ReflectionTestUtils.setField(product, "id", 1L); - product.delete(); - given(productService.getById(1L)).willReturn(product); - - List commands = List.of(new OrderItemCommand(1L, 1)); - // act - CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.createOrder(memberId, commands); - }); - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - then(orderService).should(never()).save(any()); - } - @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") @Test void throwsOnInsufficientStock() { // arrange - Long memberId = 1L; + Long userId = 1L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product, "id", 1L); StockModel stock = new StockModel(1L, 5); given(productService.getById(1L)).willReturn(product); - given(stockService.getByProductId(1L)).willReturn(stock); + given(stockService.getByProductIdForUpdate(1L)).willReturn(stock); List commands = List.of(new OrderItemCommand(1L, 10)); // act CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.createOrder(memberId, commands); + orderFacade.placeOrder(userId, commands, null); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java new file mode 100644 index 000000000..e44b92c21 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java @@ -0,0 +1,153 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductCacheIntegrationTest { + + static final GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + + static { + redisContainer.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + String host = redisContainer.getHost(); + int port = redisContainer.getFirstMappedPort(); + registry.add("datasource.redis.database", () -> 0); + registry.add("datasource.redis.master.host", () -> host); + registry.add("datasource.redis.master.port", () -> port); + registry.add("datasource.redis.replicas[0].host", () -> host); + registry.add("datasource.redis.replicas[0].port", () -> port); + } + + @Autowired + private ProductFacade productFacade; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private StockJpaRepository stockJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.clear(); + } + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("상품 상세 조회 시 캐시에 저장된다") + void 상품_상세_조회_시_캐시에_저장된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + // when + productFacade.getProduct(product.getId()); + + // then + var cachedValue = cacheManager.getCache("productDetail").get(product.getId()); + assertThat(cachedValue).isNotNull(); + } + + @Test + @DisplayName("상품 수정 시 상세 캐시가 삭제된다") + void 상품_수정_시_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + productFacade.update(product.getId(), "수정된상품", "수정된설명", new Money(20000)); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("상품 삭제 시 상세 캐시가 삭제된다") + void 상품_삭제_시_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + productFacade.delete(product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("캐시 히트 시 동일한 결과를 반환한다") + void 캐시_히트_시_동일한_결과를_반환한다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + ProductDetail firstResult = productFacade.getProduct(product.getId()); + + // when + ProductDetail secondResult = productFacade.getProduct(product.getId()); + + // then + assertThat(secondResult).isEqualTo(firstResult); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index 28572b257..a6931ffce 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -1,11 +1,9 @@ package com.loopers.application.product; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.application.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; import com.loopers.domain.stock.StockStatus; @@ -18,6 +16,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -55,21 +54,26 @@ class Register { void orchestratesRegistration() { // given Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + BrandModel brand = new BrandModel("나이키", "스포츠"); ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + ReflectionTestUtils.setField(product, "id", 1L); + StockModel stock = new StockModel(1L, 100); when(brandService.getBrand(brandId)).thenReturn(brand); when(productService.register("에어맥스", "러닝화", new Money(129000), brandId)).thenReturn(product); + when(stockService.save(1L, 100)).thenReturn(stock); + when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); + when(stockService.getByProductId(1L)).thenReturn(stock); // when - ProductModel result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); + ProductDetail result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); // then assertAll( () -> assertThat(result.name()).isEqualTo("에어맥스"), () -> verify(brandService).getBrand(brandId), () -> verify(productService).register("에어맥스", "러닝화", new Money(129000), brandId), - () -> verify(stockService).create(any(), eq(100)) + () -> verify(stockService).save(1L, 100) ); } @@ -98,10 +102,10 @@ void returnsProductDetail() { Long productId = 1L; Long brandId = 1L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + BrandModel brand = new BrandModel("나이키", "스포츠"); StockModel stock = new StockModel(productId, 100); - when(productService.getProduct(productId)).thenReturn(product); + when(productService.getById(productId)).thenReturn(product); when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); when(stockService.getByProductId(productId)).thenReturn(stock); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 93b7242f3..a734017fc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -49,7 +49,7 @@ private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } - private ProductModel createProduct(String name, String description, Money price, Long brandId, int initialStock) { + private ProductDetail createProduct(String name, String description, Money price, Long brandId, int initialStock) { return productFacade.register(name, description, price, brandId, initialStock); } @@ -64,18 +64,18 @@ void createsProductAndStock() { Long brandId = createBrand("나이키"); // when - ProductModel result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // then assertAll( - () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.id()).isNotNull(), () -> assertThat(result.name()).isEqualTo("에어맥스 90"), - () -> assertThat(result.price()).isEqualTo(new Money(129000)), + () -> assertThat(result.price()).isEqualTo(129000), () -> assertThat(result.brandId()).isEqualTo(brandId) ); - StockModel stock = stockService.getByProductId(result.getId()); - assertThat(stock.quantity()).isEqualTo(100); + StockModel stock = stockService.getByProductId(result.id()); + assertThat(stock.getQuantity()).isEqualTo(100); } @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외가 발생한다") @@ -103,13 +103,13 @@ class GetProduct { void returnsProduct() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - ProductModel result = productService.getProduct(saved.getId()); + ProductModel result = productService.getById(saved.id()); // then - assertThat(result.name()).isEqualTo("에어맥스 90"); + assertThat(result.getName()).isEqualTo("에어맥스 90"); } @DisplayName("삭제된 상품이면 NOT_FOUND 예외가 발생한다") @@ -117,12 +117,12 @@ void returnsProduct() { void throwsWhenDeleted() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - productService.delete(saved.getId()); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + productService.delete(saved.id()); // when CoreException result = assertThrows(CoreException.class, - () -> productService.getProduct(saved.getId())); + () -> productService.getById(saved.id())); // then assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -140,14 +140,14 @@ void returnsNotDeletedProducts() { Long brandId = createBrand("나이키"); createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); - ProductModel deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); - productService.delete(deleted.getId()); + ProductDetail deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); + productService.delete(deleted.id()); // when - Page result = productService.getProducts(null, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productService.getAll(PageRequest.of(0, 10), ProductSortType.CREATED_DESC); // then - assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(2); } @DisplayName("brandId로 필터링하여 조회한다") @@ -160,10 +160,10 @@ void filtersByBrandId() { createProduct("슈퍼스타", "캐주얼", new Money(99000), adidasId, 50); // when - Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productFacade.getProducts(nikeId, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); // then - assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(1); assertThat(result.getContent().get(0).name()).isEqualTo("에어맥스 90"); } } @@ -177,16 +177,16 @@ class Update { void updatesSuccessfully() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - ProductModel result = productService.update(saved.getId(), "에어맥스 95", "뉴 러닝화", new Money(159000)); + ProductModel result = productService.update(saved.id(), "에어맥스 95", "뉴 러닝화", new Money(159000)); // then assertAll( - () -> assertThat(result.name()).isEqualTo("에어맥스 95"), - () -> assertThat(result.description()).isEqualTo("뉴 러닝화"), - () -> assertThat(result.price()).isEqualTo(new Money(159000)) + () -> assertThat(result.getName()).isEqualTo("에어맥스 95"), + () -> assertThat(result.getDescription()).isEqualTo("뉴 러닝화"), + () -> assertThat(result.getPrice()).isEqualTo(new Money(159000)) ); } } @@ -200,36 +200,21 @@ class Delete { void excludedFromCustomerQueryAfterDelete() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - productService.delete(saved.getId()); + productService.delete(saved.id()); // then CoreException result = assertThrows(CoreException.class, - () -> productService.getProduct(saved.getId())); + () -> productService.getById(saved.id())); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } - - @DisplayName("soft delete 후 admin 조회에서는 포함된다") - @Test - void includedInAdminQueryAfterDelete() { - // given - Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - - // when - productService.delete(saved.getId()); - - // then - ProductModel result = productService.getProductForAdmin(saved.getId()); - assertThat(result.getDeletedAt()).isNotNull(); - } } @DisplayName("브랜드별 상품 전체 삭제") @Nested - class DeleteAllByBrandId { + class SoftDeleteByBrandId { @DisplayName("해당 브랜드의 모든 상품을 soft delete 한다") @Test @@ -240,10 +225,10 @@ void softDeletesAllProducts() { createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); // when - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); // then - Page result = productService.getProducts(brandId, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productFacade.getProducts(brandId, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); assertThat(result.getTotalElements()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index 4187b82f9..7b7b0370b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -6,11 +6,11 @@ import com.loopers.domain.product.ProductSortType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.springframework.test.util.ReflectionTestUtils; @@ -60,7 +61,7 @@ void returnsSavedProduct() { // then assertAll( - () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getName()).isEqualTo("에어맥스 90"), () -> assertThat(result.getPrice().value()).isEqualTo(129000) ); verify(productRepository).save(any(ProductModel.class)); @@ -176,8 +177,8 @@ void returnsMapOfProducts() { // then assertAll( () -> assertThat(result).hasSize(2), - () -> assertThat(result.get(1L).name()).isEqualTo("에어맥스"), - () -> assertThat(result.get(2L).name()).isEqualTo("조던") + () -> assertThat(result.get(1L).getName()).isEqualTo("에어맥스"), + () -> assertThat(result.get(2L).getName()).isEqualTo("조던") ); } @@ -231,29 +232,24 @@ class LikeCount { @DisplayName("좋아요 수를 증가시킨다") @Test - void increasesLikeCount() { + void incrementsLikeCount() { // arrange Long id = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.increaseLikeCount(id); + productService.incrementLikeCount(id); // assert - assertThat(product.getLikeCount()).isEqualTo(1); + verify(productRepository).incrementLikeCount(id); } @DisplayName("좋아요 수를 감소시킨다") @Test - void decreasesLikeCount() { + void decrementsLikeCount() { // arrange Long id = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - product.increaseLikeCount(); - given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.decreaseLikeCount(id); + productService.decrementLikeCount(id); // assert - assertThat(product.getLikeCount()).isEqualTo(0); + verify(productRepository).decrementLikeCount(id); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java index a524ebc82..25b8a0a85 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java @@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class StockServiceTest { @@ -105,8 +106,8 @@ void returnsMapOfStocks() { // then assertAll( () -> assertThat(result).hasSize(2), - () -> assertThat(result.get(1L).quantity()).isEqualTo(100), - () -> assertThat(result.get(2L).quantity()).isEqualTo(50) + () -> assertThat(result.get(1L).getQuantity()).isEqualTo(100), + () -> assertThat(result.get(2L).getQuantity()).isEqualTo(50) ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java new file mode 100644 index 000000000..c39f18fa8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java @@ -0,0 +1,66 @@ +package com.loopers.config; + +import com.loopers.config.redis.CustomCacheErrorHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; + +class CustomCacheErrorHandlerTest { + + private CustomCacheErrorHandler sut; + private Cache mockCache; + + @BeforeEach + void setUp() { + sut = new CustomCacheErrorHandler(); + mockCache = mock(Cache.class); + } + + @Test + @DisplayName("handleCacheGetError 호출 시 예외가 전파되지 않는다") + void handleCacheGetError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + + // when & then + assertDoesNotThrow(() -> sut.handleCacheGetError(exception, mockCache, key)); + } + + @Test + @DisplayName("handleCachePutError 호출 시 예외가 전파되지 않는다") + void handleCachePutError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + Object value = "testValue"; + + // when & then + assertDoesNotThrow(() -> sut.handleCachePutError(exception, mockCache, key, value)); + } + + @Test + @DisplayName("handleCacheEvictError 호출 시 예외가 전파되지 않는다") + void handleCacheEvictError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + + // when & then + assertDoesNotThrow(() -> sut.handleCacheEvictError(exception, mockCache, key)); + } + + @Test + @DisplayName("handleCacheClearError 호출 시 예외가 전파되지 않는다") + void handleCacheClearError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + + // when & then + assertDoesNotThrow(() -> sut.handleCacheClearError(exception, mockCache)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java b/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java new file mode 100644 index 000000000..f37c8ab22 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java @@ -0,0 +1,43 @@ +package com.loopers.config; + +import com.loopers.testcontainers.RedisTestContainersConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(RedisTestContainersConfig.class) +@ActiveProfiles("test") +class RedisCacheConfigTest { + + @Autowired + private CacheManager cacheManager; + + @Test + @DisplayName("CacheManager Bean이 RedisCacheManager 인스턴스여야 한다") + void cacheManager_should_be_redisCacheManager_instance() { + // given & when & then + assertThat(cacheManager).isInstanceOf(RedisCacheManager.class); + } + + @Test + @DisplayName("productDetail 캐시 설정이 존재해야 한다") + void productDetail_cache_configuration_should_exist() { + // given + RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager; + + // when + var cache = redisCacheManager.getCache("productDetail"); + + // then + assertThat(cache).isNotNull(); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index 4d25df4e0..254c3011a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.like; +import com.loopers.application.like.LikeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -29,70 +30,44 @@ class LikeServiceTest { @Mock private LikeRepository likeRepository; - @DisplayName("좋아요 등록") + @DisplayName("좋아요 저장") @Nested - class Register { + class Save { - @DisplayName("좋아요가 없으면 새로 생성하고 true를 반환한다") + @DisplayName("좋아요를 저장하고 반환한다") @Test - void returnsTrueWhenNewLike() { + void savesAndReturns() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); - given(likeRepository.save(any(LikeModel.class))).willReturn(new LikeModel(memberId, productId)); + LikeModel like = new LikeModel(userId, productId); + given(likeRepository.save(any(LikeModel.class))).willReturn(like); // act - boolean result = likeService.register(memberId, productId); + LikeModel result = likeService.save(like); // assert - assertThat(result).isTrue(); - then(likeRepository).should().save(any(LikeModel.class)); - } - - @DisplayName("이미 좋아요가 존재하면 false를 반환한다") - @Test - void returnsFalseWhenAlreadyExists() { - // arrange - Long memberId = 1L; - Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)) - .willReturn(Optional.of(new LikeModel(memberId, productId))); - // act - boolean result = likeService.register(memberId, productId); - // assert - assertThat(result).isFalse(); + assertThat(result.userId()).isEqualTo(userId); + then(likeRepository).should().save(like); } } - @DisplayName("좋아요 취소") + @DisplayName("좋아요 조회") @Nested - class Cancel { - - @DisplayName("좋아요가 존재하면 삭제하고 true를 반환한다") - @Test - void returnsTrueWhenCancelled() { - // arrange - Long memberId = 1L; - Long productId = 100L; - LikeModel like = new LikeModel(memberId, productId); - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.of(like)); - // act - boolean result = likeService.cancel(memberId, productId); - // assert - assertThat(result).isTrue(); - then(likeRepository).should().delete(like); - } + class Find { - @DisplayName("좋아요가 없으면 false를 반환한다") + @DisplayName("userId와 productId로 좋아요를 조회한다") @Test - void returnsFalseWhenNotExists() { + void findsByUserIdAndProductId() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); + LikeModel like = new LikeModel(userId, productId); + given(likeRepository.findByUserIdAndProductId(userId, productId)) + .willReturn(Optional.of(like)); // act - boolean result = likeService.cancel(memberId, productId); + Optional result = likeService.findByUserIdAndProductId(userId, productId); // assert - assertThat(result).isFalse(); + assertThat(result).isPresent(); + assertThat(result.get().productId()).isEqualTo(productId); } } @@ -104,12 +79,12 @@ class GetMyLikes { @Test void returnsPagedLikes() { // arrange - Long memberId = 1L; + Long userId = 1L; Pageable pageable = PageRequest.of(0, 10); - List likes = List.of(new LikeModel(memberId, 1L), new LikeModel(memberId, 2L)); - given(likeRepository.findAllByMemberId(memberId, pageable)).willReturn(new PageImpl<>(likes)); + List likes = List.of(new LikeModel(userId, 1L), new LikeModel(userId, 2L)); + given(likeRepository.findActiveLikesWithActiveProduct(userId, pageable)).willReturn(new PageImpl<>(likes)); // act - Page result = likeService.getMyLikes(memberId, pageable); + Page result = likeService.getMyLikes(userId, pageable); // assert assertThat(result.getContent()).hasSize(2); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 1bda80473..cdce42ce6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.application.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -29,6 +30,9 @@ class OrderServiceTest { @Mock private OrderRepository orderRepository; + @Mock + private OrderItemRepository orderItemRepository; + @DisplayName("주문 저장") @Nested class Save { @@ -37,32 +41,33 @@ class Save { @Test void savesOrder() { // arrange - OrderModel order = new OrderModel(1L); + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); given(orderRepository.save(any(OrderModel.class))).willReturn(order); // act OrderModel result = orderService.save(order); // assert - assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.userId()).isEqualTo(1L); then(orderRepository).should().save(order); } } @DisplayName("주문 조회") @Nested - class GetById { + class GetOrder { @DisplayName("존재하는 주문을 반환한다") @Test void returnsForExistingId() { // arrange Long id = 1L; - OrderModel order = new OrderModel(1L); + Long userId = 1L; + OrderModel order = new OrderModel(userId, new Money(10000), Money.ZERO, null); ReflectionTestUtils.setField(order, "id", id); given(orderRepository.findById(id)).willReturn(Optional.of(order)); // act - OrderModel result = orderService.getById(id); + OrderModel result = orderService.getOrder(id, userId); // assert - assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.userId()).isEqualTo(1L); } @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") @@ -73,7 +78,7 @@ void throwsOnNonExistentId() { given(orderRepository.findById(id)).willReturn(Optional.empty()); // act CoreException exception = assertThrows(CoreException.class, () -> { - orderService.getById(id); + orderService.getOrder(id, 1L); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java index 9c4f78e12..b89dd5fc0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java index 67152c396..20664ca28 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.api.like; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.member.MemberV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -50,7 +50,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId) { var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, 10); return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), - new ParameterizedTypeReference>() {}).getBody().data().id(); + new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java index 5b366f864..f83010ed9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -2,9 +2,9 @@ import com.loopers.infrastructure.member.MemberJpaRepository; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.member.MemberV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -59,7 +59,7 @@ private Long createProduct(String name, int price, Long brandId, int stock) { var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, stock); return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), - new ParameterizedTypeReference>() {}).getBody().data().id(); + new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java index d2ae0befe..e53288dd9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.product; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -60,7 +60,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId, int initialStock) { ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, initialStock); - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -136,7 +136,7 @@ void returns200WithStockStatus() { Long productId = createProduct("에어맥스 90", 129000, brandId, 100); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, new ParameterizedTypeReference<>() {} ); @@ -200,7 +200,7 @@ void returns200WithStockQuantity() { ); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -273,7 +273,7 @@ void returns200() { Long productId = createProduct("에어맥스 90", 129000, brandId, 100); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -302,7 +302,7 @@ void returns200WithUpdatedInfo() { ); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); diff --git a/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java b/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java new file mode 100644 index 000000000..1e60dc21b --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java @@ -0,0 +1,29 @@ +package com.loopers.config.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; + +@Slf4j +public class CustomCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn("Cache GET failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + log.warn("Cache PUT failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.warn("Cache EVICT failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.warn("Cache CLEAR failed - cache: {}, error: {}", cache.getName(), exception.getMessage()); + } +} diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java new file mode 100644 index 000000000..d2025e242 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java @@ -0,0 +1,59 @@ +package com.loopers.config.redis; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisCacheConfig implements CachingConfigurer { + + @Bean + public CacheManager cacheManager(LettuceConnectionFactory lettuceConnectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.EVERYTHING, + JsonTypeInfo.As.PROPERTY + ); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)); + + Map cacheConfigurations = Map.of( + "productDetail", defaultConfig.entryTtl(Duration.ofMinutes(10)) + ); + + return RedisCacheManager.builder(lettuceConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } + + @Override + public CacheErrorHandler errorHandler() { + return new CustomCacheErrorHandler(); + } +} From e55f078f5033d7b004aacf146b98cfd17e33d4c1 Mon Sep 17 00:00:00 2001 From: praesentia Date: Fri, 13 Mar 2026 17:48:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QueryDSL BooleanBuilder로 brandId 동적 필터 + 정렬(좋아요순 등) 구현 - 복합 인덱스 4개 추가 (brand+deleted+like, deleted+like, deleted+created, deleted+price) - N+1 문제 해결: 목록 조회 시 배치 쿼리 패턴(Map) 적용 - likes 테이블 유니크 제약조건 추가 (uk_likes_user_product) - StockService 메서드 참조 오류 수정 (productId → getProductId) Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandService.java | 12 +++++ .../application/product/ProductFacade.java | 38 ++++++++++++--- .../application/product/ProductService.java | 9 +++- .../application/stock/StockService.java | 2 +- .../com/loopers/domain/like/LikeModel.java | 5 +- .../loopers/domain/product/ProductModel.java | 8 +++- .../domain/product/ProductRepository.java | 2 + .../product/ProductRepositoryImpl.java | 46 ++++++++++++++----- 8 files changed, 100 insertions(+), 22 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 3fdc5c71d..776e7fa87 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -11,6 +11,11 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + @RequiredArgsConstructor @Component public class BrandService { @@ -65,6 +70,13 @@ public Page getAll(Pageable pageable) { return brandRepository.findAll(pageable); } + @Transactional(readOnly = true) + public Map getByIds(List ids) { + return brandRepository.findAllByIdIn(ids) + .stream() + .collect(Collectors.toMap(BrandModel::getId, Function.identity())); + } + @Transactional public BrandModel update(Long id, String name, String description) { BrandModel brand = getById(id); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 0b8eb307b..09e4029b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,6 +6,7 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockStatus; import com.loopers.application.stock.StockService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -13,6 +14,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; + @RequiredArgsConstructor @Component public class ProductFacade { @@ -48,20 +52,42 @@ public ProductDetail getProductForAdmin(Long productId) { @Transactional(readOnly = true) public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { Page products = productService.getProducts(brandId, sortType, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + return products.map(product -> { - String brandName = getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + StockModel stock = stockMap.get(product.getId()); + StockStatus status = stock != null ? StockStatus.from(stock.getQuantity()) : StockStatus.OUT_OF_STOCK; + return ProductDetail.ofCustomer(product, brandName, status); }); } @Transactional(readOnly = true) public Page getProductsForAdmin(Long brandId, Pageable pageable) { Page products = productService.getProductsForAdmin(brandId, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + return products.map(product -> { - String brandName = getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + StockModel stock = stockMap.get(product.getId()); + int stockQuantity = stock != null ? stock.getQuantity() : 0; + return ProductDetail.ofAdmin(product, brandName, stockQuantity); }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 4ceb32774..b47e66755 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -46,8 +46,13 @@ public ProductModel getProductForAdmin(Long id) { } @Transactional(readOnly = true) - public Page getAll(Pageable pageable, ProductSortType sortType) { - return productRepository.findAll(pageable, sortType); + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + return productRepository.findAll(brandId, pageable, sortType); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + return productRepository.findAll(brandId, pageable, ProductSortType.CREATED_DESC); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java index c9056c2d5..125d3ece7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java @@ -38,6 +38,6 @@ public StockModel getByProductIdForUpdate(Long productId) { public Map getByProductIds(List productIds) { return stockRepository.findAllByProductIdIn(productIds) .stream() - .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + .collect(Collectors.toMap(StockModel::getProductId, stock -> stock)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java index d8cbe4a6d..0258c33a0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -6,9 +6,12 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; @Entity -@Table(name = "likes") +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"}) +}) public class LikeModel extends BaseEntity { @Column(name = "user_id", nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 4083c2b0a..a4c3703f3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -7,10 +7,16 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; @Entity -@Table(name = "product") +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_deleted_like", columnList = "brand_id, deleted_at, like_count"), + @Index(name = "idx_product_deleted_like", columnList = "deleted_at, like_count"), + @Index(name = "idx_product_deleted_created", columnList = "deleted_at, created_at"), + @Index(name = "idx_product_deleted_price", columnList = "deleted_at, price") +}) public class ProductModel extends BaseEntity { @Column(nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 2c0eeecfc..f74a0f387 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -17,5 +17,7 @@ public interface ProductRepository { Optional findById(Long id); List findAllByBrandId(Long brandId); + Page findAll(Long brandId, Pageable pageable, ProductSortType sortType); + List findAllByIdInAndDeletedAtIsNull(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index d24e23f78..7080dd59f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -3,11 +3,14 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.QProductModel; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import java.util.List; @@ -18,6 +21,7 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; @Override public ProductModel save(ProductModel product) { @@ -45,18 +49,38 @@ public List findAllByBrandId(Long brandId) { } @Override - public Page findAll(Pageable pageable, ProductSortType sortType) { - Sort sort = toSort(sortType); - Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); - return productJpaRepository.findAllByDeletedAtIsNull(sortedPageable); + public Page findAll(Long brandId, Pageable pageable, ProductSortType sortType) { + QProductModel product = QProductModel.productModel; + + BooleanBuilder where = new BooleanBuilder(); + where.and(product.deletedAt.isNull()); + if (brandId != null) { + where.and(product.brandId.eq(brandId)); + } + + OrderSpecifier orderSpecifier = toOrderSpecifier(product, sortType); + + List content = queryFactory.selectFrom(product) + .where(where) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(product.count()) + .from(product) + .where(where) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); } - private Sort toSort(ProductSortType sortType) { + private OrderSpecifier toOrderSpecifier(QProductModel product, ProductSortType sortType) { return switch (sortType) { - case LATEST, CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); - case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); - case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "price.value"); - case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + case LATEST, CREATED_DESC -> product.createdAt.desc(); + case PRICE_ASC -> product.price.value.asc(); + case PRICE_DESC -> product.price.value.desc(); + case LIKES_DESC -> product.likeCount.desc(); }; }