diff --git a/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java b/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java index d7500ca..b3ab901 100644 --- a/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java +++ b/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java @@ -1,8 +1,7 @@ package com.msa.commerce.common.config; -import com.msa.commerce.common.config.cache.CacheDefinition; -import com.msa.commerce.common.config.cache.CacheStrategy; -import com.msa.commerce.common.config.cache.DomainCacheRegistry; +import java.time.Duration; + import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -15,58 +14,25 @@ import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; -import jakarta.annotation.PostConstruct; -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - @Configuration @EnableCaching @ConditionalOnProperty(name = "spring.data.redis.host") public class RedisConfig { - public static final String DEFAULT_CACHE = "default"; - public static final String PRODUCT_CACHE = "product"; - public static final String PRODUCT_VIEW_COUNT_CACHE = "product-view-count"; - public static final String USER_CACHE = "user"; - public static final String ORDER_CACHE = "order"; - public static final String PAYMENT_CACHE = "payment"; - - @PostConstruct - public void registerDefaultCaches() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of(PRODUCT_CACHE, CacheStrategy.LONG_TERM, - "Product basic information - master data that changes infrequently"), - CacheDefinition.of(PRODUCT_VIEW_COUNT_CACHE, CacheStrategy.SHORT_TERM, - "Product view count - real-time critical data"), - - CacheDefinition.of(USER_CACHE, CacheStrategy.MEDIUM_TERM, - "User profile information - session based data"), - - CacheDefinition.of(ORDER_CACHE, CacheStrategy.MEDIUM_TERM, - "Order information - transactional data with state changes"), - - CacheDefinition.of(PAYMENT_CACHE, CacheStrategy.SHORT_TERM, - "Payment information - rapidly changing status data"), - - CacheDefinition.of(DEFAULT_CACHE, CacheStrategy.DEFAULT, - "Default general purpose cache") - ); - } + private static final Duration DEFAULT_TTL = Duration.ofMinutes(30); @Bean public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); - + template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); - - GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); - template.setValueSerializer(serializer); - template.setHashValueSerializer(serializer); - + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + template.afterPropertiesSet(); return template; } @@ -74,81 +40,17 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) - .entryTtl(CacheStrategy.DEFAULT.getTtl()) - .disableCachingNullValues(); - - Map cacheConfigurations = buildCacheConfigurations(defaultConfig); + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())) + .entryTtl(DEFAULT_TTL) + .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(defaultConfig) - .withInitialCacheConfigurations(cacheConfigurations) - .transactionAware() - .build(); - } - - private Map buildCacheConfigurations(RedisCacheConfiguration defaultConfig) { - Map configurations = new HashMap<>(); - - DomainCacheRegistry.getAllCacheDefinitions().forEach((cacheName, cacheDefinition) -> { - RedisCacheConfiguration config = defaultConfig.entryTtl(cacheDefinition.getTtl()); - configurations.put(cacheName, config); - }); - - return configurations; + .cacheDefaults(defaultConfig) + .transactionAware() + .build(); } - public static class CacheNames { - public static final String DEFAULT = DEFAULT_CACHE; - public static final String PRODUCT = PRODUCT_CACHE; - public static final String PRODUCT_VIEW_COUNT = PRODUCT_VIEW_COUNT_CACHE; - public static final String USER = USER_CACHE; - public static final String ORDER = ORDER_CACHE; - public static final String PAYMENT = PAYMENT_CACHE; - - private CacheNames() { - } - } - - public static class CacheUtils { - - public static void registerCache(String cacheName, CacheStrategy strategy, String description) { - DomainCacheRegistry.registerCache( - CacheDefinition.of(cacheName, strategy, description) - ); - } - - public static CacheDefinition getCacheInfo(String cacheName) { - return DomainCacheRegistry.getCacheDefinition(cacheName); - } - - public static Set getAllCacheNames() { - return DomainCacheRegistry.getCacheNames(); - } - - public static boolean isCacheRegistered(String cacheName) { - return DomainCacheRegistry.isCacheRegistered(cacheName); - } - - public static String generateCacheName(String domain, String type) { - if (domain == null || domain.trim().isEmpty()) { - throw new IllegalArgumentException("Domain cannot be null or empty"); - } - if (type == null || type.trim().isEmpty()) { - throw new IllegalArgumentException("Type cannot be null or empty"); - } - return domain.toLowerCase().trim() + "-" + type.toLowerCase().trim(); - } - - public static String generateHierarchicalCacheName(String... parts) { - if (parts == null || parts.length == 0) { - throw new IllegalArgumentException("At least one part is required"); - } - return String.join(":", parts).toLowerCase(); - } - - private CacheUtils() { - } - } } diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/CacheDefinition.java b/common/src/main/java/com/msa/commerce/common/config/cache/CacheDefinition.java deleted file mode 100644 index 38903a4..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/CacheDefinition.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import java.time.Duration; - -public class CacheDefinition { - - private final String name; - private final Duration ttl; - private final String description; - private final CacheStrategy strategy; - - private CacheDefinition(Builder builder) { - this.name = builder.name; - this.ttl = builder.ttl; - this.description = builder.description; - this.strategy = builder.strategy; - } - - public String getName() { - return name; - } - - public Duration getTtl() { - return ttl; - } - - public String getDescription() { - return description; - } - - public CacheStrategy getStrategy() { - return strategy; - } - - public static class Builder { - private String name; - private Duration ttl; - private String description; - private CacheStrategy strategy = CacheStrategy.DEFAULT; - - public Builder name(String name) { - this.name = name; - return this; - } - - public Builder strategy(CacheStrategy strategy) { - this.strategy = strategy; - this.ttl = strategy.getTtl(); - return this; - } - - public Builder ttl(Duration ttl) { - this.ttl = ttl; - return this; - } - - public Builder description(String description) { - this.description = description; - return this; - } - - public CacheDefinition build() { - if (name == null || name.trim().isEmpty()) { - throw new IllegalArgumentException("Cache name is required"); - } - if (ttl == null) { - ttl = strategy.getTtl(); - } - return new CacheDefinition(this); - } - } - - public static Builder builder() { - return new Builder(); - } - - public static CacheDefinition of(String name, CacheStrategy strategy, String description) { - return builder() - .name(name) - .strategy(strategy) - .description(description) - .build(); - } - - public static CacheDefinition of(String name, CacheStrategy strategy) { - return of(name, strategy, null); - } - - @Override - public String toString() { - return String.format("CacheDefinition{name='%s', ttl=%s, strategy=%s, description='%s'}", - name, ttl, strategy, description); - } -} diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/CacheStrategy.java b/common/src/main/java/com/msa/commerce/common/config/cache/CacheStrategy.java deleted file mode 100644 index a4bdba3..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/CacheStrategy.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import java.time.Duration; - -public enum CacheStrategy { - - SHORT_TERM(Duration.ofMinutes(5)), - - MEDIUM_TERM(Duration.ofMinutes(15)), - - LONG_TERM(Duration.ofHours(1)), - - VERY_LONG_TERM(Duration.ofHours(6)), - - DAILY(Duration.ofDays(1)), - - DEFAULT(Duration.ofMinutes(30)); - - private final Duration ttl; - - CacheStrategy(Duration ttl) { - this.ttl = ttl; - } - - public Duration getTtl() { - return ttl; - } - - public long getTtlInMinutes() { - return ttl.toMinutes(); - } - - public long getTtlInSeconds() { - return ttl.getSeconds(); - } - -} diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheConfigurations.java b/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheConfigurations.java deleted file mode 100644 index 6ad345f..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheConfigurations.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.msa.commerce.common.config.cache; - -public class DomainCacheConfigurations { - - public static class ProductCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("product", CacheStrategy.LONG_TERM, - "Basic product information - master data"), - - CacheDefinition.of("product-view-count", CacheStrategy.SHORT_TERM, - "Product view count - real-time data"), - - CacheDefinition.of("product-category", CacheStrategy.VERY_LONG_TERM, - "Product category - rarely changed master data"), - - CacheDefinition.of("product-review-summary", CacheStrategy.MEDIUM_TERM, - "Product review summary - updated when reviews change"), - - CacheDefinition.of("product-stock-status", CacheStrategy.SHORT_TERM, - "Product stock status - real-time inventory data"), - - CacheDefinition.of("product:popular:by-brand", CacheStrategy.LONG_TERM, - "Popular products by brand - periodically updated aggregated data") - ); - } - } - - public static class UserCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("user", CacheStrategy.MEDIUM_TERM, - "User profile information - session based data"), - - CacheDefinition.of("user-permissions", CacheStrategy.LONG_TERM, - "User permissions - infrequently changed data"), - - CacheDefinition.of("user-session", CacheStrategy.MEDIUM_TERM, - "User session information - temporary session data"), - - CacheDefinition.of("user-preferences", CacheStrategy.LONG_TERM, - "User preferences - personal settings that change infrequently"), - - CacheDefinition.of("user:cart", CacheStrategy.MEDIUM_TERM, - "User cart - temporary session data"), - - CacheDefinition.of("user:activity:summary", CacheStrategy.SHORT_TERM, - "User activity summary - real-time activity data") - ); - } - } - - public static class OrderCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("order", CacheStrategy.MEDIUM_TERM, - "Order information - transactional data with state changes"), - - CacheDefinition.of("order-status", CacheStrategy.SHORT_TERM, - "Order status - rapidly changing status data"), - - CacheDefinition.of("order-delivery", CacheStrategy.SHORT_TERM, - "Order delivery information - real-time delivery status data"), - - CacheDefinition.of("order:stats:daily", CacheStrategy.DAILY, - "Daily order statistics - aggregated data refreshed daily"), - - CacheDefinition.of("order:recent:by-user", CacheStrategy.MEDIUM_TERM, - "Recent orders by user - user session based data") - ); - } - } - - public static class PaymentCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("payment", CacheStrategy.SHORT_TERM, - "Payment information - rapidly changing status data"), - - CacheDefinition.of("payment-status", CacheStrategy.SHORT_TERM, - "Payment status - real-time payment status data"), - - CacheDefinition.of("payment-methods", CacheStrategy.VERY_LONG_TERM, - "Payment methods - rarely changed configuration data"), - - CacheDefinition.of("payment:gateway:config", CacheStrategy.LONG_TERM, - "Payment gateway configuration - infrequently changed data"), - - CacheDefinition.of("payment:failure:stats", CacheStrategy.MEDIUM_TERM, - "Payment failure statistics - monitoring aggregated data") - ); - } - } - - public static class SystemCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("system-config", CacheStrategy.VERY_LONG_TERM, - "System configuration - rarely changed system settings"), - - CacheDefinition.of("country-codes", CacheStrategy.DAILY, - "Country codes - static master data"), - - CacheDefinition.of("exchange-rates", CacheStrategy.MEDIUM_TERM, - "Exchange rates - periodically updated external data"), - - CacheDefinition.of("api-rate-limits", CacheStrategy.MEDIUM_TERM, - "API rate limits - per-user API call limit data"), - - CacheDefinition.of("system:health", CacheStrategy.SHORT_TERM, - "System health status - real-time monitoring data") - ); - } - } - - public static void registerAllDomainCaches() { - ProductCaches.register(); - UserCaches.register(); - OrderCaches.register(); - PaymentCaches.register(); - SystemCaches.register(); - } - - public static class CacheNameHelpers { - - public static String userSpecific(String cacheType, Long userId) { - return String.format("user:%s:%d", cacheType, userId); - } - - public static String productSpecific(String cacheType, Long productId) { - return String.format("product:%s:%d", cacheType, productId); - } - - public static String orderSpecific(String cacheType, Long orderId) { - return String.format("order:%s:%d", cacheType, orderId); - } - - public static String dateSpecific(String cacheType, String date) { - return String.format("%s:date:%s", cacheType, date); - } - - public static String regionSpecific(String cacheType, String region) { - return String.format("%s:region:%s", cacheType, region); - } - - private CacheNameHelpers() { - } - } -} diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheRegistry.java b/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheRegistry.java deleted file mode 100644 index 7ed8b3e..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheRegistry.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -public class DomainCacheRegistry { - - private static final Map cacheDefinitions = new HashMap<>(); - - public static void registerCache(CacheDefinition cacheDefinition) { - if (cacheDefinition == null) { - throw new IllegalArgumentException("Cache definition cannot be null"); - } - cacheDefinitions.put(cacheDefinition.getName(), cacheDefinition); - } - - public static void registerCaches(CacheDefinition... cacheDefinitions) { - for (CacheDefinition definition : cacheDefinitions) { - registerCache(definition); - } - } - - public static CacheDefinition getCacheDefinition(String cacheName) { - return cacheDefinitions.get(cacheName); - } - - public static Map getAllCacheDefinitions() { - return new HashMap<>(cacheDefinitions); - } - - public static Set getCacheNames() { - return cacheDefinitions.keySet(); - } - - public static boolean isCacheRegistered(String cacheName) { - return cacheDefinitions.containsKey(cacheName); - } - - public static void unregisterCache(String cacheName) { - cacheDefinitions.remove(cacheName); - } - - public static void clearAll() { - cacheDefinitions.clear(); - } - - public static int size() { - return cacheDefinitions.size(); - } -} diff --git a/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java index 061fbe2..e7413c7 100644 --- a/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java +++ b/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java @@ -118,8 +118,26 @@ public ResponseEntity handleNoChangesProvidedException( return ResponseEntity.badRequest().body(errorResponse); } - @ExceptionHandler(MethodArgumentNotValidException.class) + @ExceptionHandler(ValidationException.class) public ResponseEntity handleValidationException( + ValidationException ex, HttpServletRequest request) { + + log.warn("Validation exception: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .message(ex.getMessage()) + .code(ex.getErrorCode()) + .timestamp(LocalDateTime.now()) + .path(request.getRequestURI()) + .status(HttpStatus.BAD_REQUEST.value()) + .debugInfo(isDebugMode() ? ex.getClass().getSimpleName() : null) + .build(); + + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( MethodArgumentNotValidException ex, HttpServletRequest request) { log.warn("Validation exception occurred: {}", ex.getMessage()); diff --git a/common/src/main/java/com/msa/commerce/common/exception/ValidationException.java b/common/src/main/java/com/msa/commerce/common/exception/ValidationException.java new file mode 100644 index 0000000..acdd6c0 --- /dev/null +++ b/common/src/main/java/com/msa/commerce/common/exception/ValidationException.java @@ -0,0 +1,20 @@ +package com.msa.commerce.common.exception; + +public class ValidationException extends RuntimeException { + + private final String errorCode; + + public ValidationException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + public ValidationException(String message, String errorCode, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java b/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java index 37330b6..d550b4d 100644 --- a/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java +++ b/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java @@ -1,161 +1,78 @@ package com.msa.commerce.common.config; -import com.msa.commerce.common.config.cache.CacheDefinition; -import com.msa.commerce.common.config.cache.CacheStrategy; -import com.msa.commerce.common.config.cache.DomainCacheRegistry; -import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.*; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.annotation.EnableCaching; @DisplayName("Redis Configuration Tests") class RedisConfigTest { - @BeforeEach - void setUp() { - // Clear registry before each test to ensure clean state - DomainCacheRegistry.clearAll(); - } - - @Test - @DisplayName("Should define legacy cache names constants for backward compatibility") - void shouldDefineLegacyCacheNamesConstants() { - // Given & When & Then - assertEquals("default", RedisConfig.DEFAULT_CACHE); - assertEquals("product", RedisConfig.PRODUCT_CACHE); - assertEquals("product-view-count", RedisConfig.PRODUCT_VIEW_COUNT_CACHE); - assertEquals("user", RedisConfig.USER_CACHE); - assertEquals("order", RedisConfig.ORDER_CACHE); - assertEquals("payment", RedisConfig.PAYMENT_CACHE); - } - @Test - @DisplayName("Should define CacheNames utility class constants") - void shouldDefineCacheNamesUtilityClassConstants() { - // Given & When & Then - assertEquals("default", RedisConfig.CacheNames.DEFAULT); - assertEquals("product", RedisConfig.CacheNames.PRODUCT); - assertEquals("product-view-count", RedisConfig.CacheNames.PRODUCT_VIEW_COUNT); - assertEquals("user", RedisConfig.CacheNames.USER); - assertEquals("order", RedisConfig.CacheNames.ORDER); - assertEquals("payment", RedisConfig.CacheNames.PAYMENT); - } - - @Test - @DisplayName("Should ensure cache name consistency") - void shouldEnsureCacheNameConsistency() { - // Given & When & Then - assertEquals(RedisConfig.DEFAULT_CACHE, RedisConfig.CacheNames.DEFAULT); - assertEquals(RedisConfig.PRODUCT_CACHE, RedisConfig.CacheNames.PRODUCT); - assertEquals(RedisConfig.PRODUCT_VIEW_COUNT_CACHE, RedisConfig.CacheNames.PRODUCT_VIEW_COUNT); - assertEquals(RedisConfig.USER_CACHE, RedisConfig.CacheNames.USER); - assertEquals(RedisConfig.ORDER_CACHE, RedisConfig.CacheNames.ORDER); - assertEquals(RedisConfig.PAYMENT_CACHE, RedisConfig.CacheNames.PAYMENT); - } - - @Test - @DisplayName("Should validate conditional property annotation presence") - void shouldValidateConditionalPropertyAnnotationPresence() throws Exception { + @DisplayName("Should validate configuration annotations") + void shouldValidateConfigurationAnnotations() { // Given - RedisConfig config = new RedisConfig(); - - // When - Check if the class has ConditionalOnProperty annotation - boolean hasConditionalAnnotation = config.getClass().isAnnotationPresent( - org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class); - - // Then - assertTrue(hasConditionalAnnotation, "RedisConfig should have ConditionalOnProperty annotation"); - - // Verify the property name (annotation.name() returns an array) - org.springframework.boot.autoconfigure.condition.ConditionalOnProperty annotation = - config.getClass().getAnnotation( - org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class); - assertEquals("spring.data.redis.host", annotation.name()[0]); - } + Class configClass = RedisConfig.class; - @Test - @DisplayName("Should register default caches during PostConstruct") - void shouldRegisterDefaultCachesDuringPostConstruct() { - // Given - RedisConfig config = new RedisConfig(); - assertEquals(0, DomainCacheRegistry.size()); + // Then - Verify @Configuration annotation is present + assertTrue(configClass.isAnnotationPresent( + org.springframework.context.annotation.Configuration.class), + "RedisConfig should have @Configuration annotation"); + + // Verify @EnableCaching annotation is present + assertTrue(configClass.isAnnotationPresent(EnableCaching.class), + "RedisConfig should have @EnableCaching annotation"); - // When - config.registerDefaultCaches(); + // Verify @ConditionalOnProperty annotation + assertTrue(configClass.isAnnotationPresent(ConditionalOnProperty.class), + "RedisConfig should have @ConditionalOnProperty annotation"); - // Then - assertTrue(DomainCacheRegistry.size() > 0); - assertTrue(DomainCacheRegistry.isCacheRegistered("product")); - assertTrue(DomainCacheRegistry.isCacheRegistered("product-view-count")); - assertTrue(DomainCacheRegistry.isCacheRegistered("user")); - assertTrue(DomainCacheRegistry.isCacheRegistered("order")); - assertTrue(DomainCacheRegistry.isCacheRegistered("payment")); - assertTrue(DomainCacheRegistry.isCacheRegistered("default")); + ConditionalOnProperty conditionalAnnotation = + configClass.getAnnotation(ConditionalOnProperty.class); + assertEquals("spring.data.redis.host", conditionalAnnotation.name()[0], + "Should be conditional on spring.data.redis.host property"); } @Test - @DisplayName("Should verify default cache strategies") - void shouldVerifyDefaultCacheStrategies() { + @DisplayName("Should define RedisTemplate bean method") + void shouldDefineRedisTemplateBeanMethod() throws NoSuchMethodException { // Given - RedisConfig config = new RedisConfig(); - config.registerDefaultCaches(); - - // When & Then - CacheDefinition productCache = DomainCacheRegistry.getCacheDefinition("product"); - assertEquals(CacheStrategy.LONG_TERM, productCache.getStrategy()); - - CacheDefinition viewCountCache = DomainCacheRegistry.getCacheDefinition("product-view-count"); - assertEquals(CacheStrategy.SHORT_TERM, viewCountCache.getStrategy()); - - CacheDefinition userCache = DomainCacheRegistry.getCacheDefinition("user"); - assertEquals(CacheStrategy.MEDIUM_TERM, userCache.getStrategy()); - - CacheDefinition defaultCache = DomainCacheRegistry.getCacheDefinition("default"); - assertEquals(CacheStrategy.DEFAULT, defaultCache.getStrategy()); + Class configClass = RedisConfig.class; + + // Then - Verify redisTemplate method exists + assertDoesNotThrow(() -> + configClass.getDeclaredMethod("redisTemplate", + org.springframework.data.redis.connection.RedisConnectionFactory.class), + "RedisConfig should have redisTemplate method"); + + // Verify it has @Bean annotation + var method = configClass.getDeclaredMethod("redisTemplate", + org.springframework.data.redis.connection.RedisConnectionFactory.class); + assertTrue(method.isAnnotationPresent( + org.springframework.context.annotation.Bean.class), + "redisTemplate method should have @Bean annotation"); } @Test - @DisplayName("Should test CacheUtils helper methods") - void shouldTestCacheUtilsHelperMethods() { + @DisplayName("Should define CacheManager bean method") + void shouldDefineCacheManagerBeanMethod() throws NoSuchMethodException { // Given - RedisConfig config = new RedisConfig(); - config.registerDefaultCaches(); - - // When & Then - Test cache registration - RedisConfig.CacheUtils.registerCache("test-cache", CacheStrategy.LONG_TERM, "Test cache"); - assertTrue(RedisConfig.CacheUtils.isCacheRegistered("test-cache")); - assertNotNull(RedisConfig.CacheUtils.getCacheInfo("test-cache")); - - // Test cache name generation - assertEquals("user-profile", RedisConfig.CacheUtils.generateCacheName("User", "Profile")); - assertEquals("user:profile:basic", RedisConfig.CacheUtils.generateHierarchicalCacheName("User", "Profile", "Basic")); - - // Test all cache names retrieval - assertTrue(RedisConfig.CacheUtils.getAllCacheNames().size() > 0); - assertTrue(RedisConfig.CacheUtils.getAllCacheNames().contains("product")); + Class configClass = RedisConfig.class; + + // Then - Verify cacheManager method exists + assertDoesNotThrow(() -> + configClass.getDeclaredMethod("cacheManager", + org.springframework.data.redis.connection.RedisConnectionFactory.class), + "RedisConfig should have cacheManager method"); + + // Verify it has @Bean annotation + var method = configClass.getDeclaredMethod("cacheManager", + org.springframework.data.redis.connection.RedisConnectionFactory.class); + assertTrue(method.isAnnotationPresent( + org.springframework.context.annotation.Bean.class), + "cacheManager method should have @Bean annotation"); } - @Test - @DisplayName("Should test cache name generation edge cases") - void shouldTestCacheNameGenerationEdgeCases() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName(null, "type")); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName("", "type")); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName("domain", null)); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName("domain", "")); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateHierarchicalCacheName()); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateHierarchicalCacheName(new String[]{})); - } } diff --git a/common/src/test/java/com/msa/commerce/common/config/cache/CacheDefinitionTest.java b/common/src/test/java/com/msa/commerce/common/config/cache/CacheDefinitionTest.java deleted file mode 100644 index a4b00ff..0000000 --- a/common/src/test/java/com/msa/commerce/common/config/cache/CacheDefinitionTest.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Cache Definition Tests") -class CacheDefinitionTest { - - @Test - @DisplayName("Should create cache definition with builder") - void shouldCreateCacheDefinitionWithBuilder() { - // Given & When - CacheDefinition definition = CacheDefinition.builder() - .name("test-cache") - .strategy(CacheStrategy.LONG_TERM) - .description("Test cache") - .build(); - - // Then - assertEquals("test-cache", definition.getName()); - assertEquals(CacheStrategy.LONG_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.LONG_TERM, definition.getStrategy()); - assertEquals("Test cache", definition.getDescription()); - } - - @Test - @DisplayName("Should create cache definition with factory method") - void shouldCreateCacheDefinitionWithFactoryMethod() { - // Given & When - CacheDefinition definition = CacheDefinition.of("user-cache", CacheStrategy.MEDIUM_TERM, "User profile cache"); - - // Then - assertEquals("user-cache", definition.getName()); - assertEquals(CacheStrategy.MEDIUM_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.MEDIUM_TERM, definition.getStrategy()); - assertEquals("User profile cache", definition.getDescription()); - } - - @Test - @DisplayName("Should create cache definition without description") - void shouldCreateCacheDefinitionWithoutDescription() { - // Given & When - CacheDefinition definition = CacheDefinition.of("simple-cache", CacheStrategy.SHORT_TERM); - - // Then - assertEquals("simple-cache", definition.getName()); - assertEquals(CacheStrategy.SHORT_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.SHORT_TERM, definition.getStrategy()); - assertNull(definition.getDescription()); - } - - @Test - @DisplayName("Should use custom TTL when specified") - void shouldUseCustomTtlWhenSpecified() { - // Given - Duration customTtl = Duration.ofMinutes(45); - - // When - CacheDefinition definition = CacheDefinition.builder() - .name("custom-cache") - .strategy(CacheStrategy.LONG_TERM) - .ttl(customTtl) - .build(); - - // Then - assertEquals("custom-cache", definition.getName()); - assertEquals(customTtl, definition.getTtl()); - assertEquals(CacheStrategy.LONG_TERM, definition.getStrategy()); - } - - @Test - @DisplayName("Should use strategy TTL when custom TTL is not specified") - void shouldUseStrategyTtlWhenCustomTtlNotSpecified() { - // Given & When - CacheDefinition definition = CacheDefinition.builder() - .name("strategy-cache") - .strategy(CacheStrategy.VERY_LONG_TERM) - .build(); - - // Then - assertEquals("strategy-cache", definition.getName()); - assertEquals(CacheStrategy.VERY_LONG_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.VERY_LONG_TERM, definition.getStrategy()); - } - - @Test - @DisplayName("Should use default strategy when not specified") - void shouldUseDefaultStrategyWhenNotSpecified() { - // Given & When - CacheDefinition definition = CacheDefinition.builder() - .name("default-cache") - .build(); - - // Then - assertEquals("default-cache", definition.getName()); - assertEquals(CacheStrategy.DEFAULT.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.DEFAULT, definition.getStrategy()); - } - - @Test - @DisplayName("Should throw exception when name is null") - void shouldThrowExceptionWhenNameIsNull() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - CacheDefinition.builder().build() - ); - } - - @Test - @DisplayName("Should throw exception when name is empty") - void shouldThrowExceptionWhenNameIsEmpty() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - CacheDefinition.builder().name("").build() - ); - } - - @Test - @DisplayName("Should throw exception when name is blank") - void shouldThrowExceptionWhenNameIsBlank() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - CacheDefinition.builder().name(" ").build() - ); - } - - @Test - @DisplayName("Should have meaningful toString representation") - void shouldHaveMeaningfulToStringRepresentation() { - // Given - CacheDefinition definition = CacheDefinition.of("test-cache", CacheStrategy.LONG_TERM, "Test description"); - - // When - String toString = definition.toString(); - - // Then - assertTrue(toString.contains("test-cache")); - assertTrue(toString.contains("LONG_TERM")); - assertTrue(toString.contains("Test description")); - } -} diff --git a/common/src/test/java/com/msa/commerce/common/config/cache/CacheStrategyTest.java b/common/src/test/java/com/msa/commerce/common/config/cache/CacheStrategyTest.java deleted file mode 100644 index 570d50d..0000000 --- a/common/src/test/java/com/msa/commerce/common/config/cache/CacheStrategyTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Cache Strategy Tests") -class CacheStrategyTest { - - @Test - @DisplayName("Should define correct TTL values for each strategy") - void shouldDefineCorrectTtlValues() { - // Given & When & Then - assertEquals(Duration.ofMinutes(5), CacheStrategy.SHORT_TERM.getTtl()); - assertEquals(Duration.ofMinutes(15), CacheStrategy.MEDIUM_TERM.getTtl()); - assertEquals(Duration.ofHours(1), CacheStrategy.LONG_TERM.getTtl()); - assertEquals(Duration.ofHours(6), CacheStrategy.VERY_LONG_TERM.getTtl()); - assertEquals(Duration.ofDays(1), CacheStrategy.DAILY.getTtl()); - assertEquals(Duration.ofMinutes(30), CacheStrategy.DEFAULT.getTtl()); - } - - @Test - @DisplayName("Should convert TTL to minutes correctly") - void shouldConvertTtlToMinutesCorrectly() { - // Given & When & Then - assertEquals(5, CacheStrategy.SHORT_TERM.getTtlInMinutes()); - assertEquals(15, CacheStrategy.MEDIUM_TERM.getTtlInMinutes()); - assertEquals(60, CacheStrategy.LONG_TERM.getTtlInMinutes()); - assertEquals(360, CacheStrategy.VERY_LONG_TERM.getTtlInMinutes()); - assertEquals(1440, CacheStrategy.DAILY.getTtlInMinutes()); - assertEquals(30, CacheStrategy.DEFAULT.getTtlInMinutes()); - } - - @Test - @DisplayName("Should convert TTL to seconds correctly") - void shouldConvertTtlToSecondsCorrectly() { - // Given & When & Then - assertEquals(300, CacheStrategy.SHORT_TERM.getTtlInSeconds()); - assertEquals(900, CacheStrategy.MEDIUM_TERM.getTtlInSeconds()); - assertEquals(3600, CacheStrategy.LONG_TERM.getTtlInSeconds()); - assertEquals(21600, CacheStrategy.VERY_LONG_TERM.getTtlInSeconds()); - assertEquals(86400, CacheStrategy.DAILY.getTtlInSeconds()); - assertEquals(1800, CacheStrategy.DEFAULT.getTtlInSeconds()); - } - - @Test - @DisplayName("Should have ascending TTL values") - void shouldHaveAscendingTtlValues() { - // Given & When & Then - assertTrue(CacheStrategy.SHORT_TERM.getTtlInSeconds() < CacheStrategy.MEDIUM_TERM.getTtlInSeconds()); - assertTrue(CacheStrategy.MEDIUM_TERM.getTtlInSeconds() < CacheStrategy.DEFAULT.getTtlInSeconds()); - assertTrue(CacheStrategy.DEFAULT.getTtlInSeconds() < CacheStrategy.LONG_TERM.getTtlInSeconds()); - assertTrue(CacheStrategy.LONG_TERM.getTtlInSeconds() < CacheStrategy.VERY_LONG_TERM.getTtlInSeconds()); - assertTrue(CacheStrategy.VERY_LONG_TERM.getTtlInSeconds() < CacheStrategy.DAILY.getTtlInSeconds()); - } -} diff --git a/common/src/test/java/com/msa/commerce/common/config/cache/DomainCacheRegistryTest.java b/common/src/test/java/com/msa/commerce/common/config/cache/DomainCacheRegistryTest.java deleted file mode 100644 index c7881b5..0000000 --- a/common/src/test/java/com/msa/commerce/common/config/cache/DomainCacheRegistryTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Domain Cache Registry Tests") -class DomainCacheRegistryTest { - - @BeforeEach - void setUp() { - // Clear registry before each test - DomainCacheRegistry.clearAll(); - } - - @Test - @DisplayName("Should register cache definition") - void shouldRegisterCacheDefinition() { - // Given - CacheDefinition definition = CacheDefinition.of("test-cache", CacheStrategy.LONG_TERM); - - // When - DomainCacheRegistry.registerCache(definition); - - // Then - assertTrue(DomainCacheRegistry.isCacheRegistered("test-cache")); - assertEquals(definition, DomainCacheRegistry.getCacheDefinition("test-cache")); - assertEquals(1, DomainCacheRegistry.size()); - } - - @Test - @DisplayName("Should register multiple cache definitions") - void shouldRegisterMultipleCacheDefinitions() { - // Given - CacheDefinition cache1 = CacheDefinition.of("cache1", CacheStrategy.SHORT_TERM); - CacheDefinition cache2 = CacheDefinition.of("cache2", CacheStrategy.MEDIUM_TERM); - CacheDefinition cache3 = CacheDefinition.of("cache3", CacheStrategy.LONG_TERM); - - // When - DomainCacheRegistry.registerCaches(cache1, cache2, cache3); - - // Then - assertEquals(3, DomainCacheRegistry.size()); - assertTrue(DomainCacheRegistry.isCacheRegistered("cache1")); - assertTrue(DomainCacheRegistry.isCacheRegistered("cache2")); - assertTrue(DomainCacheRegistry.isCacheRegistered("cache3")); - } - - @Test - @DisplayName("Should return null for non-existent cache") - void shouldReturnNullForNonExistentCache() { - // Given & When - CacheDefinition definition = DomainCacheRegistry.getCacheDefinition("non-existent"); - - // Then - assertNull(definition); - assertFalse(DomainCacheRegistry.isCacheRegistered("non-existent")); - } - - @Test - @DisplayName("Should return all cache definitions") - void shouldReturnAllCacheDefinitions() { - // Given - CacheDefinition cache1 = CacheDefinition.of("cache1", CacheStrategy.SHORT_TERM); - CacheDefinition cache2 = CacheDefinition.of("cache2", CacheStrategy.MEDIUM_TERM); - DomainCacheRegistry.registerCaches(cache1, cache2); - - // When - var allDefinitions = DomainCacheRegistry.getAllCacheDefinitions(); - - // Then - assertEquals(2, allDefinitions.size()); - assertTrue(allDefinitions.containsKey("cache1")); - assertTrue(allDefinitions.containsKey("cache2")); - assertEquals(cache1, allDefinitions.get("cache1")); - assertEquals(cache2, allDefinitions.get("cache2")); - } - - @Test - @DisplayName("Should return all cache names") - void shouldReturnAllCacheNames() { - // Given - DomainCacheRegistry.registerCaches( - CacheDefinition.of("user-cache", CacheStrategy.MEDIUM_TERM), - CacheDefinition.of("product-cache", CacheStrategy.LONG_TERM) - ); - - // When - var cacheNames = DomainCacheRegistry.getCacheNames(); - - // Then - assertEquals(2, cacheNames.size()); - assertTrue(cacheNames.contains("user-cache")); - assertTrue(cacheNames.contains("product-cache")); - } - - @Test - @DisplayName("Should unregister cache") - void shouldUnregisterCache() { - // Given - CacheDefinition definition = CacheDefinition.of("temp-cache", CacheStrategy.SHORT_TERM); - DomainCacheRegistry.registerCache(definition); - assertTrue(DomainCacheRegistry.isCacheRegistered("temp-cache")); - - // When - DomainCacheRegistry.unregisterCache("temp-cache"); - - // Then - assertFalse(DomainCacheRegistry.isCacheRegistered("temp-cache")); - assertNull(DomainCacheRegistry.getCacheDefinition("temp-cache")); - assertEquals(0, DomainCacheRegistry.size()); - } - - @Test - @DisplayName("Should clear all caches") - void shouldClearAllCaches() { - // Given - DomainCacheRegistry.registerCaches( - CacheDefinition.of("cache1", CacheStrategy.SHORT_TERM), - CacheDefinition.of("cache2", CacheStrategy.MEDIUM_TERM), - CacheDefinition.of("cache3", CacheStrategy.LONG_TERM) - ); - assertEquals(3, DomainCacheRegistry.size()); - - // When - DomainCacheRegistry.clearAll(); - - // Then - assertEquals(0, DomainCacheRegistry.size()); - assertTrue(DomainCacheRegistry.getCacheNames().isEmpty()); - } - - @Test - @DisplayName("Should throw exception when registering null cache definition") - void shouldThrowExceptionWhenRegisteringNullCacheDefinition() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - DomainCacheRegistry.registerCache(null) - ); - } - - @Test - @DisplayName("Should overwrite existing cache definition with same name") - void shouldOverwriteExistingCacheDefinitionWithSameName() { - // Given - CacheDefinition original = CacheDefinition.of("same-cache", CacheStrategy.SHORT_TERM, "Original"); - CacheDefinition updated = CacheDefinition.of("same-cache", CacheStrategy.LONG_TERM, "Updated"); - - DomainCacheRegistry.registerCache(original); - assertEquals(original, DomainCacheRegistry.getCacheDefinition("same-cache")); - - // When - DomainCacheRegistry.registerCache(updated); - - // Then - assertEquals(updated, DomainCacheRegistry.getCacheDefinition("same-cache")); - assertEquals(1, DomainCacheRegistry.size()); - assertEquals("Updated", DomainCacheRegistry.getCacheDefinition("same-cache").getDescription()); - } -} diff --git a/docs/api-specs/monolith-openapi.yml b/docs/api-specs/monolith-openapi.yml index 0d9fc8a..0df309e 100644 --- a/docs/api-specs/monolith-openapi.yml +++ b/docs/api-specs/monolith-openapi.yml @@ -581,6 +581,85 @@ paths: path: "/api/v1/products/1" status: 500 + delete: + tags: + - Products + summary: Delete product + description: | + Delete a product using soft delete pattern. + + The product is not physically removed but marked as deleted with a timestamp. + Validates that the product doesn't have active orders or items in shopping carts. + + Business Rules: + - Product status changes to ARCHIVED + - Related data handling (images, inventory) + - Cache invalidation for deleted products + - Domain event publishing for product deletion + operationId: deleteProduct + parameters: + - name: id + in: path + required: true + description: Product ID to delete + schema: + type: integer + format: int64 + minimum: 1 + example: 1 + responses: + '204': + description: Product successfully deleted (soft delete) + '400': + description: Cannot delete product due to business constraints + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + product_in_use: + summary: Product has active orders + value: + message: "Product cannot be deleted due to active orders" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 400 + product_in_cart: + summary: Product exists in shopping carts + value: + message: "Product cannot be deleted as it exists in shopping carts" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 400 + '404': + description: Product not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + not_found: + summary: Product not found + value: + message: "Product not found with id: 1" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 404 + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + server_error: + summary: Server error + value: + message: "Internal server error occurred" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 500 + components: schemas: ProductCreateRequest: diff --git a/materialized-view/src/main/java/com/msa/commerce/materializedview/MaterializedViewApplication.java b/materialized-view/src/main/java/com/msa/commerce/materializedview/MaterializedViewApplication.java new file mode 100644 index 0000000..9cb27fb --- /dev/null +++ b/materialized-view/src/main/java/com/msa/commerce/materializedview/MaterializedViewApplication.java @@ -0,0 +1,13 @@ +package com.msa.commerce.materializedview; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MaterializedViewApplication { + + public static void main(String[] args) { + SpringApplication.run(MaterializedViewApplication.class, args); + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java index db536be..c50774c 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java @@ -3,6 +3,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; +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; @@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductPageResponse; import com.msa.commerce.monolith.product.application.port.in.ProductResponse; @@ -32,6 +34,8 @@ public class ProductController { private final ProductUpdateUseCase productUpdateUseCase; + private final ProductDeleteUseCase productDeleteUseCase; + private final ProductMapper productMapper; @PostMapping @@ -56,4 +60,10 @@ public ResponseEntity updateProduct(@PathVariable("id") Long pr return ResponseEntity.ok(productUpdateUseCase.updateProduct(productMapper.toUpdateCommand(productId, request))); } + @DeleteMapping("/{id}") + public ResponseEntity deleteProduct(@PathVariable("id") Long productId) { + productDeleteUseCase.deleteProduct(productId); + return ResponseEntity.noContent().build(); + } + } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/event/ProductEventPublisherImpl.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/event/ProductEventPublisherImpl.java new file mode 100644 index 0000000..c7a680c --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/event/ProductEventPublisherImpl.java @@ -0,0 +1,32 @@ +package com.msa.commerce.monolith.product.adapter.out.event; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import com.msa.commerce.monolith.product.application.service.ProductEventPublisher; +import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductDeletedEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventPublisherImpl implements ProductEventPublisher { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public void publishProductDeletedEvent(Product product) { + ProductDeletedEvent event = new ProductDeletedEvent( + product.getId(), + product.getSku(), + product.getName(), + product.getDeletedAt() + ); + + eventPublisher.publishEvent(event); + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java index 7995e52..cff04d5 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java @@ -100,6 +100,9 @@ public class ProductJpaEntity { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Version @Column(nullable = false) private Long version = 1L; @@ -127,6 +130,7 @@ public static ProductJpaEntity fromDomainEntity(Product product) { jpaEntity.primaryImageUrl = product.getPrimaryImageUrl(); jpaEntity.createdAt = product.getCreatedAt(); jpaEntity.updatedAt = product.getUpdatedAt(); + jpaEntity.deletedAt = product.getDeletedAt(); jpaEntity.version = product.getVersion(); return jpaEntity; } @@ -177,6 +181,7 @@ public Product toDomainEntity() { this.primaryImageUrl, this.createdAt, this.updatedAt, + this.deletedAt, this.version ); } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/port/in/ProductDeleteUseCase.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/port/in/ProductDeleteUseCase.java new file mode 100644 index 0000000..fb38b4b --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/port/in/ProductDeleteUseCase.java @@ -0,0 +1,7 @@ +package com.msa.commerce.monolith.product.application.port.in; + +public interface ProductDeleteUseCase { + + void deleteProduct(Long productId); + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java index 6ac4f87..a4a573b 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java @@ -1,5 +1,6 @@ package com.msa.commerce.monolith.product.application.service; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +12,7 @@ import com.msa.commerce.monolith.product.application.port.in.ProductResponse; import com.msa.commerce.monolith.product.application.port.out.ProductRepository; import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; import lombok.RequiredArgsConstructor; @@ -23,6 +25,8 @@ public class ProductCreateService implements ProductCreateUseCase { private final ProductResponseMapper productResponseMapper; + private final ApplicationEventPublisher applicationEventPublisher; + @Override @ValidateCommand(errorPrefix = "Product creation validation failed") public ProductResponse createProduct(ProductCreateCommand command) { @@ -53,6 +57,10 @@ private ProductResponse executeProductCreation(ProductCreateCommand command) { .build(); Product savedProduct = productRepository.save(product); + + // 통합 이벤트 발행 (트랜잭션 커밋 후 캐시 무효화 처리) + applicationEventPublisher.publishEvent(ProductEvent.productCreated(savedProduct)); + return productResponseMapper.toResponse(savedProduct); } @@ -70,26 +78,23 @@ private void validateCommand(ProductCreateCommand command) { if (command.getSku() == null || command.getSku().trim().isEmpty()) { throw new IllegalArgumentException("SKU is required"); } - + if (command.getName() == null || command.getName().trim().isEmpty()) { throw new IllegalArgumentException("Product name is required."); } - + if (command.getBasePrice() == null) { throw new IllegalArgumentException("Base price must be greater than 0."); } - + if (command.getBasePrice().signum() <= 0) { throw new IllegalArgumentException("Base price must be greater than 0."); } - + if (command.getSlug() == null || command.getSlug().trim().isEmpty()) { throw new IllegalArgumentException("Slug is required"); } - - // Optional validation for categoryId - based on business logic, it might be required - // The test expects exception when categoryId is null, but the annotation doesn't mark it as @NotNull - // Let's check if this validation is needed based on the failing test + if (command.getCategoryId() == null) { throw new IllegalArgumentException("Category ID is required."); } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java new file mode 100644 index 0000000..560c1f8 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java @@ -0,0 +1,61 @@ +package com.msa.commerce.monolith.product.application.service; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.msa.commerce.common.exception.ErrorCode; +import com.msa.commerce.common.exception.ResourceNotFoundException; +import com.msa.commerce.common.exception.ValidationException; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; +import com.msa.commerce.monolith.product.application.port.out.ProductRepository; +import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class ProductDeleteService implements ProductDeleteUseCase { + + private final ProductRepository productRepository; + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void deleteProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + productId, ErrorCode.PRODUCT_NOT_FOUND.getCode())); + + validateProductDeletable(product); + + product.softDelete(); + Product deletedProduct = productRepository.save(product); + + handleProductDeletion(productId); + + applicationEventPublisher.publishEvent(ProductEvent.productDeleted(deletedProduct)); + } + + private void validateProductDeletable(Product product) { + if (product.isDeleted()) { + throw new ValidationException("Product is already deleted", ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode()); + } + + // TODO: 진행 중인 주문 확인 + + // TODO: 장바구니 포함 여부 확인 + } + + private void handleProductDeletion(Long productId) { + // TODO: 상품 이미지 상태 변경 (실제 파일은 유지, 상태만 변경) + // TODO: 이미지 서비스가 구현되면 호출 + + // TODO: 재고 정보 처리 (재고는 유지하되 상태를 비활성화) + // TODO: 재고 서비스가 구현되면 호출 + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductEventPublisher.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductEventPublisher.java new file mode 100644 index 0000000..7113dff --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductEventPublisher.java @@ -0,0 +1,9 @@ +package com.msa.commerce.monolith.product.application.service; + +import com.msa.commerce.monolith.product.domain.Product; + +public interface ProductEventPublisher { + + void publishProductDeletedEvent(Product product); + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java index d04a3ea..d80ee45 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java @@ -4,7 +4,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.msa.commerce.common.config.RedisConfig; import com.msa.commerce.common.exception.ResourceNotFoundException; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductPageResponse; @@ -36,7 +35,7 @@ public ProductResponse getProduct(Long productId) { } @Override - @Cacheable(value = RedisConfig.PRODUCT_CACHE, key = "#productId", condition = "#increaseViewCount == false") + @Cacheable(value = "product", key = "#productId", condition = "#increaseViewCount == false") public ProductResponse getProduct(Long productId, boolean increaseViewCount) { Product product = productRepository.findById(productId) .orElseThrow( diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java index 749cd86..311b514 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java @@ -3,6 +3,7 @@ import java.util.Optional; import java.util.Set; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +17,7 @@ import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; import com.msa.commerce.monolith.product.application.port.out.ProductRepository; import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; @@ -31,9 +33,11 @@ public class ProductUpdateService implements ProductUpdateUseCase { private final ProductRepository productRepository; private final ProductResponseMapper productResponseMapper; - + private final Validator validator; + private final ApplicationEventPublisher applicationEventPublisher; + @Override @ValidateCommand(errorPrefix = "Product update validation failed") public ProductResponse updateProduct(ProductUpdateCommand command) { @@ -45,9 +49,9 @@ public ProductResponse updateProduct(ProductUpdateCommand command) { updateProductData(existingProduct, command); Product updatedProduct = productRepository.save(existingProduct); - performPostUpdateOperations(updatedProduct, command, existingProduct); + // 통합 이벤트 발행 (트랜잭션 커밋 후 캐시 무효화 처리) + applicationEventPublisher.publishEvent(ProductEvent.productUpdated(updatedProduct)); - log.info("Product updated successfully with ID: {}", updatedProduct.getId()); return productResponseMapper.toResponse(updatedProduct); } @@ -101,64 +105,6 @@ private void updateProductData(Product product, ProductUpdateCommand command) { ); } - private void performPostUpdateOperations(Product updatedProduct, ProductUpdateCommand command, - Product originalProduct) { - invalidateProductCache(updatedProduct.getId()); - logProductChanges(originalProduct, updatedProduct, command); - } - - private void logProductChanges(Product before, Product after, ProductUpdateCommand command) { - log.info("Product changes for ID: {} - Updated fields: {}", - after.getId(), getUpdatedFieldsDescription(command)); - } - - private String getUpdatedFieldsDescription(ProductUpdateCommand command) { - StringBuilder description = new StringBuilder(); - - appendBasicFieldUpdates(description, command); - appendPriceFieldUpdates(description, command); - appendMetadataFieldUpdates(description, command); - - return description.length() > 0 ? description.substring(0, description.length() - 1) : "none"; - } - - private void appendBasicFieldUpdates(StringBuilder description, ProductUpdateCommand command) { - appendFieldIfNotNull(description, command.getSku(), "sku"); - appendFieldIfNotNull(description, command.getName(), "name"); - appendFieldIfNotNull(description, command.getShortDescription(), "shortDescription"); - appendFieldIfNotNull(description, command.getDescription(), "description"); - appendFieldIfNotNull(description, command.getCategoryId(), "categoryId"); - appendFieldIfNotNull(description, command.getBrand(), "brand"); - appendFieldIfNotNull(description, command.getProductType(), "productType"); - } - - private void appendPriceFieldUpdates(StringBuilder description, ProductUpdateCommand command) { - appendFieldIfNotNull(description, command.getBasePrice(), "basePrice"); - appendFieldIfNotNull(description, command.getSalePrice(), "salePrice"); - appendFieldIfNotNull(description, command.getCurrency(), "currency"); - appendFieldIfNotNull(description, command.getWeightGrams(), "weightGrams"); - } - - private void appendMetadataFieldUpdates(StringBuilder description, ProductUpdateCommand command) { - appendFieldIfNotNull(description, command.getRequiresShipping(), "requiresShipping"); - appendFieldIfNotNull(description, command.getIsTaxable(), "isTaxable"); - appendFieldIfNotNull(description, command.getIsFeatured(), "isFeatured"); - appendFieldIfNotNull(description, command.getSlug(), "slug"); - appendFieldIfNotNull(description, command.getSearchTags(), "searchTags"); - appendFieldIfNotNull(description, command.getPrimaryImageUrl(), "primaryImageUrl"); - } - - private void appendFieldIfNotNull(StringBuilder description, Object field, String fieldName) { - if (field != null) { - description.append(fieldName).append(","); - } - } - - private void invalidateProductCache(Long productId) { - log.debug("Invalidating cache for product ID: {}", productId); - log.info("Cache invalidation completed for product ID: {}", productId); - } - private void validateCommand(ProductUpdateCommand command) { Set> violations = validator.validate(command); if (!violations.isEmpty()) { diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java index ec5c690..60da02b 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java @@ -54,6 +54,8 @@ public class Product { private LocalDateTime updatedAt; + private LocalDateTime deletedAt; + private Long version; @Builder @@ -92,7 +94,7 @@ public static Product reconstitute(Long id, String sku, String name, String shor ProductStatus status, BigDecimal basePrice, BigDecimal salePrice, String currency, Integer weightGrams, Boolean requiresShipping, Boolean isTaxable, Boolean isFeatured, String slug, String searchTags, String primaryImageUrl, LocalDateTime createdAt, - LocalDateTime updatedAt, Long version) { + LocalDateTime updatedAt, LocalDateTime deletedAt, Long version) { Product product = new Product(); product.id = id; product.sku = sku; @@ -115,6 +117,7 @@ public static Product reconstitute(Long id, String sku, String name, String shor product.primaryImageUrl = primaryImageUrl; product.createdAt = createdAt; product.updatedAt = updatedAt; + product.deletedAt = deletedAt; product.version = version; return product; } @@ -206,12 +209,19 @@ public boolean canBeUpdatedBy(String userId) { public void deactivate() { this.status = ProductStatus.INACTIVE; - this.updatedAt = LocalDateTime.now(); } public void activate() { this.status = ProductStatus.ACTIVE; - this.updatedAt = LocalDateTime.now(); + } + + public void softDelete() { + this.status = ProductStatus.ARCHIVED; + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return this.deletedAt != null; } private void validateProduct(String sku, String name, BigDecimal basePrice) { diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductDeletedEvent.java b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductDeletedEvent.java new file mode 100644 index 0000000..73fd076 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductDeletedEvent.java @@ -0,0 +1,22 @@ +package com.msa.commerce.monolith.product.domain.event; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +@Getter +@AllArgsConstructor +@ToString +public class ProductDeletedEvent { + + private final Long productId; + + private final String sku; + + private final String productName; + + private final LocalDateTime deletedAt; + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java new file mode 100644 index 0000000..b604517 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java @@ -0,0 +1,28 @@ +package com.msa.commerce.monolith.product.domain.event; + +import com.msa.commerce.monolith.product.domain.Product; + +public record ProductEvent( + Product product, + Long productId, + EventType eventType +) { + + public enum EventType { + PRODUCT_CREATED, + PRODUCT_UPDATED, + PRODUCT_DELETED + } + + public static ProductEvent productCreated(Product product) { + return new ProductEvent(product, product.getId(), EventType.PRODUCT_CREATED); + } + + public static ProductEvent productUpdated(Product product) { + return new ProductEvent(product, product.getId(), EventType.PRODUCT_UPDATED); + } + + public static ProductEvent productDeleted(Product product) { + return new ProductEvent(product, product.getId(), EventType.PRODUCT_DELETED); + } +} \ No newline at end of file diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java new file mode 100644 index 0000000..383bf08 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java @@ -0,0 +1,84 @@ +package com.msa.commerce.monolith.product.infrastructure.event; + +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.msa.commerce.monolith.product.application.service.ProductEventPublisher; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventListener { + + private static final String PRODUCT_CACHE = "product"; + + private static final String PRODUCTS_CACHE = "products"; + + private final CacheManager cacheManager; + + private final ProductEventPublisher productEventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductEvent(ProductEvent event) { + // 캐시 무효화 처리 + invalidateCache(event); + + // 외부 이벤트 발행 + publishExternalEvent(event); + } + + private void invalidateCache(ProductEvent event) { + switch (event.eventType()) { + case PRODUCT_CREATED: + evictProductsCache(); + break; + + case PRODUCT_UPDATED: + case PRODUCT_DELETED: + evictProductCache(event.productId()); + evictProductsCache(); + break; + } + } + + private void publishExternalEvent(ProductEvent event) { + switch (event.eventType()) { + case PRODUCT_DELETED: + productEventPublisher.publishProductDeletedEvent(event.product()); + break; + + case PRODUCT_CREATED: + log.debug("Product created event for productId: {}", event.productId()); + break; + + case PRODUCT_UPDATED: + log.debug("Product updated event for productId: {}", event.productId()); + break; + } + } + + private void evictProductCache(Long productId) { + if (productId != null) { + var cache = cacheManager.getCache(PRODUCT_CACHE); + if (cache != null) { + cache.evict(productId); + log.debug("Evicted product cache for productId: {}", productId); + } + } + } + + private void evictProductsCache() { + var cache = cacheManager.getCache(PRODUCTS_CACHE); + if (cache != null) { + cache.clear(); + log.debug("Cleared all products cache"); + } + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventPublisherImpl.java b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventPublisherImpl.java new file mode 100644 index 0000000..6fb83ca --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventPublisherImpl.java @@ -0,0 +1,27 @@ +package com.msa.commerce.monolith.product.infrastructure.event; + +import org.springframework.stereotype.Component; + +import com.msa.commerce.monolith.product.application.service.ProductEventPublisher; +import com.msa.commerce.monolith.product.domain.Product; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class ProductEventPublisherImpl implements ProductEventPublisher { + + @Override + public void publishProductDeletedEvent(Product product) { + // TODO: 실제 이벤트 발행 로직 구현 + // 예: Kafka, RabbitMQ, SNS 등으로 이벤트 전송 + log.info("Publishing product deleted event for productId: {}, sku: {}", + product.getId(), product.getSku()); + + // 외부 시스템으로 이벤트 전송 로직 + // 예시: + // kafkaProducer.send("product.deleted", productDeletedEvent); + // rabbitTemplate.convertAndSend("product.exchange", "product.deleted", event); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerDeleteTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerDeleteTest.java new file mode 100644 index 0000000..53bc683 --- /dev/null +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerDeleteTest.java @@ -0,0 +1,120 @@ +package com.msa.commerce.monolith.product.adapter.in.web; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.msa.commerce.common.exception.ErrorCode; +import com.msa.commerce.common.exception.ResourceNotFoundException; +import com.msa.commerce.common.exception.ValidationException; +import com.msa.commerce.common.monitoring.MetricsCollector; +import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; + +@WebMvcTest(ProductController.class) +@DisplayName("ProductController 삭제 API 단위 테스트") +class ProductControllerDeleteTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ProductCreateUseCase productCreateUseCase; + + @MockitoBean + private ProductGetUseCase productGetUseCase; + + @MockitoBean + private ProductUpdateUseCase productUpdateUseCase; + + @MockitoBean + private ProductDeleteUseCase productDeleteUseCase; + + @MockitoBean + private ProductMapper productMapper; + + @MockitoBean + private MetricsCollector metricsCollector; + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 정상적으로 상품 삭제") + @WithMockUser + void deleteProduct_Success() throws Exception { + // given + Long productId = 1L; + doNothing().when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isNoContent()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 존재하지 않는 상품 삭제 시 404 반환") + @WithMockUser + void deleteProduct_NotFound() throws Exception { + // given + Long productId = 999L; + doThrow(new ResourceNotFoundException("Product not found", ErrorCode.PRODUCT_NOT_FOUND.getCode())) + .when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isNotFound()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 진행 중인 주문이 있는 상품 삭제 시 400 반환") + @WithMockUser + void deleteProduct_WithActiveOrders() throws Exception { + // given + Long productId = 1L; + doThrow(new ValidationException("Cannot delete product with active orders", + ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode())) + .when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 이미 삭제된 상품 삭제 시 400 반환") + @WithMockUser + void deleteProduct_AlreadyDeleted() throws Exception { + // given + Long productId = 1L; + doThrow(new ValidationException("Product is already deleted", + ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode())) + .when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java index 8f33d79..82f0a61 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java @@ -21,6 +21,7 @@ import com.msa.commerce.common.monitoring.MetricsCollector; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductPageResponse; import com.msa.commerce.monolith.product.application.port.in.ProductSearchCommand; @@ -46,6 +47,9 @@ class ProductControllerSearchTest { @MockitoBean private ProductUpdateUseCase productUpdateUseCase; + @MockitoBean + private ProductDeleteUseCase productDeleteUseCase; + @MockitoBean private ProductSearchUseCase productSearchUseCase; diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java index 8305e69..e4bbcc8 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java @@ -22,9 +22,9 @@ import com.msa.commerce.common.exception.DuplicateResourceException; import com.msa.commerce.common.exception.ErrorCode; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductResponse; -import com.msa.commerce.monolith.product.application.port.in.ProductSearchUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; import com.msa.commerce.monolith.product.domain.ProductStatus; @@ -44,7 +44,7 @@ class ProductControllerTest { private ProductUpdateUseCase productUpdateUseCase; @Mock - private ProductSearchUseCase productSearchUseCase; + private ProductDeleteUseCase productDeleteUseCase; @Mock private ProductMapper productMapper; @@ -53,7 +53,7 @@ class ProductControllerTest { void setUp() { mockMvc = MockMvcBuilders.standaloneSetup( new ProductController(productCreateUseCase, productGetUseCase, productUpdateUseCase, - productMapper)) + productDeleteUseCase, productMapper)) .setControllerAdvice(new com.msa.commerce.common.exception.GlobalExceptionHandler()) .build(); } diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java index 3a5753d..1cd9c6c 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java @@ -22,9 +22,9 @@ import com.msa.commerce.common.exception.ErrorCode; import com.msa.commerce.common.exception.ResourceNotFoundException; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductResponse; -import com.msa.commerce.monolith.product.application.port.in.ProductSearchUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductUpdateCommand; import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; import com.msa.commerce.monolith.product.domain.ProductStatus; @@ -45,25 +45,15 @@ class ProductControllerUpdateTest { private ProductUpdateUseCase productUpdateUseCase; @Mock - private ProductSearchUseCase productSearchUseCase; + private ProductDeleteUseCase productDeleteUseCase; @Mock private ProductMapper productMapper; - // private final ProductCreateUseCase productCreateUseCase; - // - // private final ProductGetUseCase productGetUseCase; - // - // private final ProductUpdateUseCase productUpdateUseCase; - // - // private final ProductSearchUseCase productSearchUseCase; - // - // private final ProductMapper productMapper; - @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup( - new ProductController(productCreateUseCase, productGetUseCase, productUpdateUseCase, productMapper)) + new ProductController(productCreateUseCase, productGetUseCase, productUpdateUseCase, productDeleteUseCase, productMapper)) .setControllerAdvice(new com.msa.commerce.common.exception.GlobalExceptionHandler()) .build(); } diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java index 357857e..52406a2 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java @@ -41,6 +41,7 @@ void fromDomainEntity_ShouldMapCorrectly() { "https://example.com/image.jpg", // primaryImageUrl now, // createdAt now, // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java index 23553ad..ac799bb 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java @@ -14,14 +14,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import com.msa.commerce.common.exception.DuplicateResourceException; import com.msa.commerce.monolith.product.application.port.in.ProductCreateCommand; import com.msa.commerce.monolith.product.application.port.in.ProductResponse; import com.msa.commerce.monolith.product.application.port.out.ProductRepository; import com.msa.commerce.monolith.product.domain.Product; -import com.msa.commerce.monolith.product.domain.ProductType; import com.msa.commerce.monolith.product.domain.ProductStatus; +import com.msa.commerce.monolith.product.domain.ProductType; @ExtendWith(MockitoExtension.class) @DisplayName("ProductCreateService 테스트") @@ -33,6 +34,9 @@ class ProductCreateServiceTest { @Mock private ProductResponseMapper productResponseMapper; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @InjectMocks private ProductCreateService productCreateService; @@ -75,6 +79,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now(), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); } diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java new file mode 100644 index 0000000..7377e27 --- /dev/null +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java @@ -0,0 +1,152 @@ +package com.msa.commerce.monolith.product.application.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import com.msa.commerce.common.exception.ErrorCode; +import com.msa.commerce.common.exception.ResourceNotFoundException; +import com.msa.commerce.common.exception.ValidationException; +import com.msa.commerce.monolith.product.application.port.out.ProductRepository; +import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.ProductStatus; +import com.msa.commerce.monolith.product.domain.ProductType; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProductDeleteService 단위 테스트") +class ProductDeleteServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private ProductDeleteService productDeleteService; + + private Product testProduct; + + private Long productId; + + @BeforeEach + void setUp() { + productId = 1L; + testProduct = Product.reconstitute( + productId, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ACTIVE, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + null, + 1L + ); + } + + @Test + @DisplayName("정상적으로 상품을 삭제할 수 있다") + void deleteProduct_Success() { + // given + when(productRepository.findById(productId)).thenReturn(Optional.of(testProduct)); + when(productRepository.save(any(Product.class))).thenReturn(testProduct); + + // when + productDeleteService.deleteProduct(productId); + + // then + verify(productRepository).findById(productId); + verify(productRepository).save(any(Product.class)); + verify(applicationEventPublisher).publishEvent(any(ProductEvent.class)); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 ResourceNotFoundException 발생") + void deleteProduct_NotFound() { + // given + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productDeleteService.deleteProduct(productId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Product not found with id: " + productId) + .extracting("errorCode") + .isEqualTo(ErrorCode.PRODUCT_NOT_FOUND.getCode()); + + verify(productRepository).findById(productId); + verify(productRepository, never()).save(any()); + } + + @Test + @DisplayName("이미 삭제된 상품 삭제 시 ValidationException 발생") + void deleteProduct_AlreadyDeleted() { + // given + Product deletedProduct = Product.reconstitute( + productId, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ARCHIVED, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + LocalDateTime.now().minusHours(1), // 이미 삭제됨 + 1L + ); + + when(productRepository.findById(productId)).thenReturn(Optional.of(deletedProduct)); + + // when & then + assertThatThrownBy(() -> productDeleteService.deleteProduct(productId)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Product is already deleted") + .extracting("errorCode") + .isEqualTo(ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode()); + + verify(productRepository).findById(productId); + verify(productRepository, never()).save(any()); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java index adaf0ca..7d7eb21 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; -import static org.mockito.Mockito.*; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -73,6 +72,7 @@ void setUp() { null, // primaryImageUrl now, // createdAt now, // updatedAt + null, // deletedAt 1L // version ); @@ -98,6 +98,7 @@ void setUp() { null, // primaryImageUrl now, // createdAt now, // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java index e95039e..b01ee24 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java @@ -47,6 +47,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now(), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); } @@ -108,6 +109,7 @@ void toResponse_ShouldHandleNullValues() { null, // primaryImageUrl null LocalDateTime.now(), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java index c7e3c84..854f2dd 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java @@ -75,6 +75,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(1), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); @@ -100,6 +101,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(2), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java index 23af267..b0ddf60 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java @@ -17,6 +17,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import com.msa.commerce.common.exception.DuplicateResourceException; import com.msa.commerce.common.exception.ProductUpdateNotAllowedException; @@ -40,10 +41,13 @@ class ProductUpdateServiceTest { @Mock private ProductResponseMapper productResponseMapper; - + @Mock private Validator validator; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @InjectMocks private ProductUpdateService productUpdateService; @@ -77,6 +81,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(1), // createdAt LocalDateTime.now().minusDays(1), // updatedAt + null, // deletedAt 1L // version ); @@ -155,7 +160,7 @@ void updateProduct_ProductNotUpdatable_ThrowsException() { 1L, null, ProductType.PHYSICAL, ProductStatus.ARCHIVED, new BigDecimal("10000"), null, "KRW", null, true, true, false, "archived-product", null, null, - LocalDateTime.now().minusDays(1), LocalDateTime.now().minusDays(1), 1L + LocalDateTime.now().minusDays(1), LocalDateTime.now().minusDays(1), null, 1L ); given(productRepository.findById(1L)).willReturn(Optional.of(archivedProduct)); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductSoftDeleteTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductSoftDeleteTest.java new file mode 100644 index 0000000..d460e52 --- /dev/null +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductSoftDeleteTest.java @@ -0,0 +1,154 @@ +package com.msa.commerce.monolith.product.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Product 소프트 삭제 단위 테스트") +class ProductSoftDeleteTest { + + private Product product; + + @BeforeEach + void setUp() { + product = Product.builder() + .sku("TEST-SKU-001") + .name("Test Product") + .shortDescription("Short description") + .description("Detailed description") + .categoryId(1L) + .brand("Test Brand") + .productType(ProductType.PHYSICAL) + .basePrice(new BigDecimal("10000")) + .salePrice(new BigDecimal("8000")) + .currency("KRW") + .weightGrams(500) + .requiresShipping(true) + .isTaxable(true) + .isFeatured(false) + .slug("test-product") + .searchTags("test,product") + .primaryImageUrl("http://example.com/image.jpg") + .build(); + } + + @Test + @DisplayName("softDelete 호출 시 상태가 ARCHIVED로 변경되고 deletedAt이 설정된다") + void softDelete_Success() { + // given + assertThat(product.getStatus()).isNotEqualTo(ProductStatus.ARCHIVED); + assertThat(product.getDeletedAt()).isNull(); + assertThat(product.isDeleted()).isFalse(); + + LocalDateTime beforeDelete = LocalDateTime.now(); + + // when + product.softDelete(); + + // then + assertThat(product.getStatus()).isEqualTo(ProductStatus.ARCHIVED); + assertThat(product.getDeletedAt()).isNotNull(); + assertThat(product.getDeletedAt()).isAfterOrEqualTo(beforeDelete); + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("이미 삭제된 상품도 다시 softDelete를 호출할 수 있다") + void softDelete_AlreadyDeleted() { + // given + product.softDelete(); + LocalDateTime firstDeletedAt = product.getDeletedAt(); + assertThat(product.isDeleted()).isTrue(); + + // when + product.softDelete(); // 다시 삭제 + + // then + assertThat(product.getStatus()).isEqualTo(ProductStatus.ARCHIVED); + assertThat(product.getDeletedAt()).isNotNull(); + assertThat(product.getDeletedAt()).isAfterOrEqualTo(firstDeletedAt); + assertThat(product.isDeleted()).isTrue(); + } + + @Test + @DisplayName("reconstitute로 생성한 삭제된 상품도 isDeleted가 true를 반환한다") + void reconstitute_WithDeletedAt() { + // given + LocalDateTime deletedAt = LocalDateTime.now().minusHours(1); + + // when + Product deletedProduct = Product.reconstitute( + 1L, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ARCHIVED, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + deletedAt, + 1L + ); + + // then + assertThat(deletedProduct.isDeleted()).isTrue(); + assertThat(deletedProduct.getDeletedAt()).isEqualTo(deletedAt); + assertThat(deletedProduct.getStatus()).isEqualTo(ProductStatus.ARCHIVED); + } + + @Test + @DisplayName("reconstitute로 생성한 삭제되지 않은 상품은 isDeleted가 false를 반환한다") + void reconstitute_WithoutDeletedAt() { + // when + Product activeProduct = Product.reconstitute( + 1L, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ACTIVE, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + null, // deletedAt이 null + 1L + ); + + // then + assertThat(activeProduct.isDeleted()).isFalse(); + assertThat(activeProduct.getDeletedAt()).isNull(); + assertThat(activeProduct.getStatus()).isEqualTo(ProductStatus.ACTIVE); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java index 269f7c4..78cfdc5 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java @@ -42,6 +42,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(1), // createdAt LocalDateTime.now().minusDays(1), // updatedAt + null, // deletedAt 1L // version ); } @@ -160,7 +161,7 @@ void isUpdatable_ActiveProduct_ReturnsTrue() { 1L, null, ProductType.PHYSICAL, ProductStatus.ACTIVE, new BigDecimal("10000"), null, "KRW", null, true, true, false, "active-product", null, null, - LocalDateTime.now(), LocalDateTime.now(), 1L + LocalDateTime.now(), LocalDateTime.now(), null, 1L ); // when & then @@ -176,7 +177,7 @@ void isUpdatable_ArchivedProduct_ReturnsFalse() { 1L, null, ProductType.PHYSICAL, ProductStatus.ARCHIVED, new BigDecimal("10000"), null, "KRW", null, true, true, false, "archived-product", null, null, - LocalDateTime.now(), LocalDateTime.now(), 1L + LocalDateTime.now(), LocalDateTime.now(), null, 1L ); // when & then diff --git a/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java b/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java index e69de29..f64b9fa 100644 --- a/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java +++ b/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java @@ -0,0 +1,12 @@ +package com.msa.commerce.orchestrator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OrderOrchestratorApplication { + + public static void main(String[] args) { + SpringApplication.run(OrderOrchestratorApplication.class, args); + } +} \ No newline at end of file