diff --git a/dateroad-api/build.gradle b/dateroad-api/build.gradle index b7db8e4c..fbeb74e6 100644 --- a/dateroad-api/build.gradle +++ b/dateroad-api/build.gradle @@ -12,6 +12,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + testImplementation 'io.github.autoparams:autoparams:8.3.0' } jar.enabled = false diff --git a/dateroad-api/src/main/java/org/dateroad/common/GlobalExceptionHandler.java b/dateroad-api/src/main/java/org/dateroad/common/GlobalExceptionHandler.java index db34619c..6757325d 100644 --- a/dateroad-api/src/main/java/org/dateroad/common/GlobalExceptionHandler.java +++ b/dateroad-api/src/main/java/org/dateroad/common/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package org.dateroad.common; +import io.lettuce.core.RedisConnectionException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.dateroad.code.FailureCode; @@ -62,4 +63,13 @@ protected ResponseEntity handleException(final Exception e) { final FailureResponse response = FailureResponse.of(FailureCode.INTERNAL_SERVER_ERROR, errors); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } + + @ExceptionHandler(RedisConnectionException.class) + protected ResponseEntity handleRedisConnectionException(final RedisConnectionException e) { + log.error(">>> handle: RedisConnectionException ", e); + String errorMessage = "Redis connection error: " + e.getMessage(); + List errors = FailureResponse.FieldError.of("RedisConnection", "", errorMessage); + final FailureResponse response = FailureResponse.of(FailureCode.REDIS_CONNECTION_ERROR, errors); + return new ResponseEntity<>(response, HttpStatus.SERVICE_UNAVAILABLE); + } } diff --git a/dateroad-api/src/main/java/org/dateroad/config/RedisClusterConfig.java b/dateroad-api/src/main/java/org/dateroad/config/RedisClusterConfig.java index bd5720ce..50a91baf 100644 --- a/dateroad-api/src/main/java/org/dateroad/config/RedisClusterConfig.java +++ b/dateroad-api/src/main/java/org/dateroad/config/RedisClusterConfig.java @@ -1,14 +1,12 @@ package org.dateroad.config; -import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; import io.lettuce.core.ReadFrom; import io.lettuce.core.SocketOptions; import io.lettuce.core.cluster.ClusterClientOptions; import io.lettuce.core.cluster.ClusterTopologyRefreshOptions; -import io.lettuce.core.cluster.models.partitions.RedisClusterNode.NodeFlag; +import io.lettuce.core.cluster.models.partitions.RedisClusterNode; import java.time.Duration; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; @@ -25,24 +23,20 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; -import org.springframework.transaction.annotation.EnableTransactionManagement; @Configuration +@Slf4j public class RedisClusterConfig { @Value("${aws.ip}") private String host; + @Value("${spring.data.redis.cluster.password}") private String password; - private RedisConnectionFactory redisConnectionFactory; @Bean @Primary public RedisConnectionFactory redisConnectionFactoryForCluster() { - if (this.redisConnectionFactory != null) { - return this.redisConnectionFactory; - } - RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration() .clusterNode(host, 7001) .clusterNode(host, 7002) @@ -52,7 +46,7 @@ public RedisConnectionFactory redisConnectionFactoryForCluster() { .clusterNode(host, 7006); clusterConfig.setPassword(RedisPassword.of(password)); SocketOptions socketOptions = SocketOptions.builder() - .connectTimeout(Duration.ofSeconds(3L)) + .connectTimeout(Duration.ofSeconds(5L)) .tcpNoDelay(true) .keepAlive(true) .build(); @@ -61,26 +55,28 @@ public RedisConnectionFactory redisConnectionFactoryForCluster() { .builder() .dynamicRefreshSources(true) .enableAllAdaptiveRefreshTriggers() - .enablePeriodicRefresh(Duration.ofHours(1L)) + .enablePeriodicRefresh() // 60초마다 refresh + .refreshTriggersReconnectAttempts(3) // 재연결 시도 후 갱신 .build(); ClusterClientOptions clusterClientOptions = ClusterClientOptions .builder() - .pingBeforeActivateConnection(true) + .socketOptions(socketOptions) + .pingBeforeActivateConnection(true) // 연결 활성화 전에 ping .autoReconnect(true) .topologyRefreshOptions(clusterTopologyRefreshOptions) - .nodeFilter(it -> - !(it.is(NodeFlag.EVENTUAL_FAIL) - || it.is(NodeFlag.FAIL) - || it.is(NodeFlag.NOADDR) - || it.is(NodeFlag.HANDSHAKE))) .validateClusterNodeMembership(false) - .maxRedirects(5).build(); + .nodeFilter(it -> + ! (it.is(RedisClusterNode.NodeFlag.FAIL) + || it.is(RedisClusterNode.NodeFlag.EVENTUAL_FAIL) + || it.is(RedisClusterNode.NodeFlag.HANDSHAKE) + || it.is(RedisClusterNode.NodeFlag.NOADDR))) + .maxRedirects(3).build(); final LettuceClientConfiguration clientConfig = LettuceClientConfiguration .builder() .readFrom(ReadFrom.REPLICA_PREFERRED) - .commandTimeout(Duration.ofSeconds(10L)) + .commandTimeout(Duration.ofSeconds(5L)) // 명령 타임아웃 5초로 설정 .clientOptions(clusterClientOptions) .build(); @@ -89,10 +85,10 @@ public RedisConnectionFactory redisConnectionFactoryForCluster() { factory.setValidateConnection(false); factory.setShareNativeConnection(true); - this.redisConnectionFactory = factory; // 재사용을 위해 저장 return factory; } + @Bean public RedisTemplate redistemplateForCluster() { RedisTemplate redisTemplate = new RedisTemplate<>(); @@ -101,7 +97,6 @@ public RedisTemplate redistemplateForCluster() { redisTemplate.setValueSerializer(new StringRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new StringRedisSerializer()); -// redisTemplate.setEnableTransactionSupport(true); return redisTemplate; } diff --git a/dateroad-api/src/main/java/org/dateroad/config/RedisStreamConfig.java b/dateroad-api/src/main/java/org/dateroad/config/RedisStreamConfig.java deleted file mode 100644 index 324ae451..00000000 --- a/dateroad-api/src/main/java/org/dateroad/config/RedisStreamConfig.java +++ /dev/null @@ -1,98 +0,0 @@ -package org.dateroad.config; - - - -import lombok.RequiredArgsConstructor; -import org.dateroad.point.event.FreeEventListener; -import org.dateroad.point.event.PointEventListener; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisClusterConnection; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.stream.*; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.stream.StreamMessageListenerContainer; -import org.springframework.data.redis.stream.StreamMessageListenerContainer.StreamMessageListenerContainerOptions; -import org.springframework.data.redis.stream.Subscription; - -import java.time.Duration; -import java.util.Iterator; - -@Configuration -@RequiredArgsConstructor -public class RedisStreamConfig { - private final PointEventListener pointEventListener; - private final FreeEventListener freeEventListener; - private final RedisTemplate redistemplateForCluster; - - public void createStreamConsumerGroup(final String streamKey, final String consumerGroupName) { - if (Boolean.FALSE.equals(redistemplateForCluster.hasKey(streamKey))) { - // Stream이 존재하지 않을 경우, MKSTREAM 옵션으로 스트림과 그룹을 생성 - redistemplateForCluster.execute((RedisCallback) connection -> { - if (connection.isPipelined() || connection.isQueueing()) { - throw new UnsupportedOperationException("Pipelined or queued connections are not supported for cluster."); - } - byte[] streamKeyBytes = streamKey.getBytes(); - byte[] consumerGroupNameBytes = consumerGroupName.getBytes(); - - if (connection instanceof RedisClusterConnection clusterConnection) { - // 클러스터 모드에서 명령 실행 - clusterConnection.execute("XGROUP", "CREATE".getBytes(), streamKeyBytes, consumerGroupNameBytes, "0".getBytes(), "MKSTREAM".getBytes()); - } else { - // 비클러스터 모드에서 명령 실행 - connection.execute("XGROUP", "CREATE".getBytes(), streamKeyBytes, consumerGroupNameBytes, "0".getBytes(), "MKSTREAM".getBytes()); - } - return null; - }); - } else { - // Stream이 존재할 경우 ConsumerGroup 존재 여부 확인 후 생성 - if (!isStreamConsumerGroupExist(streamKey, consumerGroupName)) { - redistemplateForCluster.opsForStream().createGroup(streamKey, ReadOffset.from("0"), consumerGroupName); - } - } - } - - public boolean isStreamConsumerGroupExist(final String streamKey, final String consumerGroupName) { - Iterator iterator = redistemplateForCluster - .opsForStream().groups(streamKey).stream().iterator(); - while (iterator.hasNext()) { - StreamInfo.XInfoGroup xInfoGroup = iterator.next(); - if (xInfoGroup.groupName().equals(consumerGroupName)) { - return true; - } - } - return false; - } - - @Bean - public Subscription pointSubscription(RedisConnectionFactory redisConnectionFactoryForCluster) { - createStreamConsumerGroup("coursePoint", "coursePointGroup"); - StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions - .builder().pollTimeout(Duration.ofMillis(500)).build(); - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create( - redisConnectionFactoryForCluster, containerOptions); - Subscription subscription = container.receiveAutoAck(Consumer.from("coursePointGroup", "instance-1"), - StreamOffset.create("coursePoint", ReadOffset.lastConsumed()), pointEventListener); - container.start(); - return subscription; - } - - @Bean - public Subscription freeSubscription(RedisConnectionFactory redisConnectionFactoryForCluster) { - createStreamConsumerGroup("courseFree", "courseFreeGroup"); - StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions - .builder().pollTimeout(Duration.ofMillis(500)) - .build(); - - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create( - redisConnectionFactoryForCluster, - containerOptions); - Subscription subscription = container.receiveAutoAck(Consumer.from("courseFreeGroup", "instance-2"), - StreamOffset.create("courseFree", ReadOffset.lastConsumed()), freeEventListener); - container.start(); - return subscription; - } -} - diff --git a/dateroad-api/src/main/java/org/dateroad/config/RedisStreamSubscriber.java b/dateroad-api/src/main/java/org/dateroad/config/RedisStreamSubscriber.java new file mode 100644 index 00000000..e1b916d1 --- /dev/null +++ b/dateroad-api/src/main/java/org/dateroad/config/RedisStreamSubscriber.java @@ -0,0 +1,141 @@ +package org.dateroad.config; + + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dateroad.point.event.FreeEventListener; +import org.dateroad.point.event.PointEventListener; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.stream.StreamListener; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.data.redis.stream.StreamMessageListenerContainer.StreamMessageListenerContainerOptions; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +@EnableScheduling +public class RedisStreamSubscriber { + + private final PointEventListener pointEventListener; + private final FreeEventListener freeEventListener; + private final RedisTemplate redistemplateForCluster; + private final RedisConnectionFactory redisConnectionFactoryForCluster; + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + private StreamMessageListenerContainer> pointListenerContainer; + private StreamMessageListenerContainer> freeListenerContainer; + + @PostConstruct + public void createConsumer() { + createStreamConsumerGroup("coursePoint", "coursePointGroup"); + createStreamConsumerGroup("courseFree", "courseFreeGroup"); + } + + public void createStreamConsumerGroup(final String streamKey, final String consumerGroupName) { + boolean streamExists = Boolean.TRUE.equals(redistemplateForCluster.hasKey(streamKey)); + if (!streamExists) { + redistemplateForCluster.execute((RedisCallback) connection -> { + byte[] streamKeyBytes = streamKey.getBytes(); + byte[] consumerGroupNameBytes = consumerGroupName.getBytes(); + connection.execute("XGROUP", "CREATE".getBytes(), streamKeyBytes, consumerGroupNameBytes, + "0".getBytes(), "MKSTREAM".getBytes()); + return null; + }); + } else if (!isStreamConsumerGroupExist(streamKey, consumerGroupName)) { + redistemplateForCluster.opsForStream().createGroup(streamKey, ReadOffset.from("0"), consumerGroupName); + } + } + + public boolean isStreamConsumerGroupExist(final String streamKey, final String consumerGroupName) { + return redistemplateForCluster + .opsForStream().groups(streamKey).stream() + .anyMatch(group -> group.groupName().equals(consumerGroupName)); + } + + @Bean + public StreamMessageListenerContainer> startPointListener() { + pointListenerContainer = createStreamSubscription( + "coursePoint", "coursePointGroup", "instance-1", pointEventListener + ); + return pointListenerContainer; + } + + @Bean + public StreamMessageListenerContainer> startFreeListener() { + freeListenerContainer = createStreamSubscription( + "courseFree", "courseFreeGroup", "instance-2", freeEventListener + ); + return freeListenerContainer; + } + + private StreamMessageListenerContainer> createStreamSubscription( + String streamKey, String consumerGroup, String consumerName, + StreamListener> eventListener) { + + StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions + .builder() + .pollTimeout(Duration.ofSeconds(1L)) + .errorHandler(e -> { + log.error("Error in listener: {}", e.getMessage()); + restartSubscription(streamKey, consumerGroup, consumerName, eventListener); + }).build(); + + StreamMessageListenerContainer> container = + StreamMessageListenerContainer.create(redisConnectionFactoryForCluster, containerOptions); + + container.register( + StreamMessageListenerContainer.StreamReadRequest.builder( + StreamOffset.create(streamKey, ReadOffset.lastConsumed())) + .cancelOnError(t -> true) // 오류 발생 시 구독 취소 + .consumer(Consumer.from(consumerGroup, consumerName)) + .autoAcknowledge(true) + .build(), eventListener); + + container.start(); + log.info("Listener container started for stream: {}", streamKey); + return container; + } + + private void restartSubscription(String streamKey, String consumerGroup, String consumerName, + StreamListener> eventListener) { + scheduler.schedule(() -> { + log.info("Restarting subscription for stream: {}", streamKey); + stopContainer(streamKey); + createStreamSubscription(streamKey, consumerGroup, consumerName, eventListener).start(); + }, 5, TimeUnit.SECONDS); // 일정 시간 후 재시작 + } + + private void stopContainer(String streamKey) { + if ("coursePoint".equals(streamKey) && pointListenerContainer != null && pointListenerContainer.isRunning()) { + pointListenerContainer.stop(); + log.info("Stopped point listener container"); + } + if ("courseFree".equals(streamKey) && freeListenerContainer != null && freeListenerContainer.isRunning()) { + freeListenerContainer.stop(); + log.info("Stopped free listener container"); + } + } + + @PreDestroy + public void onDestroy() { + stopContainer("coursePoint"); + stopContainer("courseFree"); + scheduler.shutdown(); + log.info("All listener containers stopped and scheduler shutdown."); + } +} \ No newline at end of file diff --git a/dateroad-api/src/main/java/org/dateroad/course/dto/request/CourseCreateReq.java b/dateroad-api/src/main/java/org/dateroad/course/dto/request/CourseCreateReq.java index e9483ebf..3065adf9 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/dto/request/CourseCreateReq.java +++ b/dateroad-api/src/main/java/org/dateroad/course/dto/request/CourseCreateReq.java @@ -28,7 +28,7 @@ @Getter @Builder(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor public class CourseCreateReq { @Size(min = 5) private String title; diff --git a/dateroad-api/src/main/java/org/dateroad/course/dto/request/CoursePlaceGetReq.java b/dateroad-api/src/main/java/org/dateroad/course/dto/request/CoursePlaceGetReq.java index 213aa748..ae6c1ca9 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/dto/request/CoursePlaceGetReq.java +++ b/dateroad-api/src/main/java/org/dateroad/course/dto/request/CoursePlaceGetReq.java @@ -11,7 +11,7 @@ @Getter @Builder(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor public class CoursePlaceGetReq { private String title; private float duration; diff --git a/dateroad-api/src/main/java/org/dateroad/course/dto/request/TagCreateReq.java b/dateroad-api/src/main/java/org/dateroad/course/dto/request/TagCreateReq.java index 80f76afc..41094a7b 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/dto/request/TagCreateReq.java +++ b/dateroad-api/src/main/java/org/dateroad/course/dto/request/TagCreateReq.java @@ -13,7 +13,7 @@ @Setter @Builder(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor public class TagCreateReq{ private DateTagType tag; diff --git a/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseDtoGetRes.java b/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseDtoGetRes.java index 49aad611..9f3cfaa9 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseDtoGetRes.java +++ b/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseDtoGetRes.java @@ -1,7 +1,9 @@ package org.dateroad.course.dto.response; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.NoArgsConstructor; import org.dateroad.date.domain.Course; import org.dateroad.date.domain.Region; diff --git a/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseGetAllRes.java b/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseGetAllRes.java index 9b95d6be..32f7337c 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseGetAllRes.java +++ b/dateroad-api/src/main/java/org/dateroad/course/dto/response/CourseGetAllRes.java @@ -2,7 +2,9 @@ import java.util.List; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; @Builder(access = AccessLevel.PRIVATE) public record CourseGetAllRes( diff --git a/dateroad-api/src/main/java/org/dateroad/course/service/AsyncService.java b/dateroad-api/src/main/java/org/dateroad/course/service/AsyncService.java index ce95bbcc..0224e570 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/service/AsyncService.java +++ b/dateroad-api/src/main/java/org/dateroad/course/service/AsyncService.java @@ -8,14 +8,16 @@ import java.util.Map; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.dateroad.code.FailureCode; -import org.dateroad.exception.DateRoadException; -import org.dateroad.image.service.ImageService; import org.dateroad.course.dto.request.CoursePlaceGetReq; import org.dateroad.course.dto.request.PointUseReq; import org.dateroad.course.dto.request.TagCreateReq; import org.dateroad.date.domain.Course; +import org.dateroad.exception.DateRoadException; import org.dateroad.image.domain.Image; +import org.dateroad.image.service.ImageService; +import org.springframework.dao.QueryTimeoutException; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +26,7 @@ @Service @RequiredArgsConstructor(access = AccessLevel.PROTECTED) @Transactional(readOnly = true) +@Slf4j public class AsyncService { private final CoursePlaceService coursePlaceService; private final CourseTagService courseTagService; @@ -44,17 +47,33 @@ public void createCoursePlace(final List places, final Course public void publishEvenUserPoint(final Long userId, PointUseReq pointUseReq) { Map fieldMap = new HashMap<>(); - fieldMap.put("userId", userId.toString()); - fieldMap.put("point", String.valueOf(pointUseReq.getPoint())); - fieldMap.put("type", pointUseReq.getType().name()); - fieldMap.put("description", pointUseReq.getDescription()); - redistemplateForCluster.opsForStream().add("coursePoint", fieldMap); + try { + fieldMap.put("userId", userId.toString()); + fieldMap.put("point", String.valueOf(pointUseReq.getPoint())); + fieldMap.put("type", pointUseReq.getType().name()); + fieldMap.put("description", pointUseReq.getDescription()); + redistemplateForCluster.opsForStream().add("coursePoint", fieldMap); + } catch (QueryTimeoutException e) { + log.error("Redis command timed out for userId: {} - Retrying...", userId, e); + throw new DateRoadException(FailureCode.REDIS_CONNECTION_ERROR); + } catch (Exception e) { + log.error("Unexpected error while publishing point event for userId: {}", userId, e); + throw new DateRoadException(FailureCode.REDIS_CONNECTION_ERROR); + } } public void publishEventUserFree(final Long userId) { Map fieldMap = new HashMap<>(); - fieldMap.put("userId", userId.toString()); - redistemplateForCluster.opsForStream().add("courseFree", fieldMap); + try { + fieldMap.put("userId", userId.toString()); + redistemplateForCluster.opsForStream().add("courseFree", fieldMap); + } catch (QueryTimeoutException e) { + log.error("Redis command timed out for userId: {} - Retrying...", userId, e); + throw new DateRoadException(FailureCode.REDIS_CONNECTION_ERROR); + } catch (Exception e) { + log.error("Unexpected error while publishing free event for userId: {}", userId, e); + throw new DateRoadException(FailureCode.REDIS_CONNECTION_ERROR); + } } @Transactional diff --git a/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java b/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java index 9b4c3add..33cca009 100644 --- a/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java +++ b/dateroad-api/src/main/java/org/dateroad/course/service/CourseService.java @@ -152,7 +152,7 @@ public DateAccessGetAllRes getAllDataAccessCourse(final Long userId) { return DateAccessGetAllRes.of(courseDtoGetResList); } - private User getUser(final Long userId) { + public User getUser(final Long userId) { return userRepository.findUserById(userId) .orElseThrow(() -> new EntityNotFoundException(FailureCode.USER_NOT_FOUND)); } diff --git a/dateroad-api/src/main/java/org/dateroad/image/service/ImageService.java b/dateroad-api/src/main/java/org/dateroad/image/service/ImageService.java index 88b15767..97695d44 100644 --- a/dateroad-api/src/main/java/org/dateroad/image/service/ImageService.java +++ b/dateroad-api/src/main/java/org/dateroad/image/service/ImageService.java @@ -7,7 +7,10 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,6 +31,7 @@ public class ImageService { private final ImageRepository imageRepository; private final S3Service s3Service; + @Value("${s3.bucket.path}") private String path; @Value("${cloudfront.domain}") @@ -35,36 +39,33 @@ public class ImageService { @Transactional public List saveImages(final List images, final Course course) { - List savedImages = Collections.synchronizedList(new ArrayList<>()); // 동기화된 리스트 사용 - List threads = IntStream.range(0, images.size()) - .mapToObj(index -> Thread.startVirtualThread(() -> { - try { - String imagePath = s3Service.uploadImage(path, images.get(index)); // S3 업로드 - Image newImage = Image.create( - course, - cachePath + imagePath, // 이미지 URL 생성 - index + 1 // 입력받은 순서대로 시퀀스 부여 - ); - synchronized (savedImages) { - savedImages.add(newImage); // 동기화된 리스트에 이미지 추가 + List savedImages = new ArrayList<>(); + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = IntStream.range(0, images.size()) + .mapToObj(index -> CompletableFuture.runAsync(() -> { + try { + String imagePath = s3Service.uploadImage(path, images.get(index)); + Image newImage = Image.create( + course, + cachePath + imagePath, + index + 1 + ); + savedImages.add(newImage); + } catch (IOException e) { + throw new BadRequestException(FailureCode.BAD_REQUEST); } - } catch (IOException e) { - throw new BadRequestException(FailureCode.BAD_REQUEST); - } - })) - .toList(); - for (Thread thread : threads) { - try { - thread.join(); - } catch (InterruptedException e) { - throw new BadRequestException(FailureCode.BAD_REQUEST); - } + }, executor)) + .toList(); + // Wait for all tasks to complete + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + savedImages.sort(Comparator.comparing(Image::getSequence)); + imageRepository.saveAll(savedImages); + executor.shutdown(); // Shutdown the ExecutorService } - savedImages.sort(Comparator.comparing(Image::getSequence)); - imageRepository.saveAll(savedImages); return savedImages; } + public String getImageUrl(final MultipartFile image) { if (image == null || image.isEmpty()) { return null; diff --git a/dateroad-api/src/main/java/org/dateroad/point/event/FreeEventListener.java b/dateroad-api/src/main/java/org/dateroad/point/event/FreeEventListener.java index 7262e3b5..3e9b5706 100644 --- a/dateroad-api/src/main/java/org/dateroad/point/event/FreeEventListener.java +++ b/dateroad-api/src/main/java/org/dateroad/point/event/FreeEventListener.java @@ -10,8 +10,10 @@ import org.dateroad.user.domain.User; import org.dateroad.user.repository.UserRepository; import org.dateroad.user.service.UserService; +import org.springframework.beans.factory.DisposableBean; import org.springframework.cache.annotation.CachePut; import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.stream.StreamListener; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -21,11 +23,13 @@ @Slf4j public class FreeEventListener implements StreamListener> { private final UserService userService; - + private final RedisTemplate redistemplateForCluster; @Override @Transactional public void onMessage(final MapRecord message) { try { + String stream = message.getStream(); + String recordId = message.getId().getValue(); Map map = message.getValue(); Long userId = Long.valueOf(map.get("userId")); User user = userService.getUser(userId); @@ -33,6 +37,7 @@ public void onMessage(final MapRecord message) { user.setFree(userFree - 1); userService.saveUser(user); log.info("Redis onMessage[FREE]:{}:BEFORE:{} => AFTER:{}", user.getId(),userFree,user.getFree()); + redistemplateForCluster.opsForStream().acknowledge("courseFree", "courseFreeGroup", recordId); }catch (Exception e) { log.error("redis Listener Error:ERROR: {}", e.getMessage()); throw new DateRoadException(FailureCode.POINT_CREATE_ERROR); diff --git a/dateroad-api/src/main/java/org/dateroad/point/event/PointEventListener.java b/dateroad-api/src/main/java/org/dateroad/point/event/PointEventListener.java index c7f49075..f146f187 100644 --- a/dateroad-api/src/main/java/org/dateroad/point/event/PointEventListener.java +++ b/dateroad-api/src/main/java/org/dateroad/point/event/PointEventListener.java @@ -6,40 +6,45 @@ import lombok.extern.slf4j.Slf4j; import org.dateroad.code.FailureCode; import org.dateroad.exception.DateRoadException; -import org.dateroad.exception.EntityNotFoundException; import org.dateroad.exception.UnauthorizedException; import org.dateroad.point.domain.Point; import org.dateroad.point.domain.TransactionType; import org.dateroad.point.repository.PointRepository; import org.dateroad.user.domain.User; -import org.dateroad.user.repository.UserRepository; import org.dateroad.user.service.UserService; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.CachePut; +import org.springframework.data.redis.RedisSystemException; import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.stream.StreamListener; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor(access = AccessLevel.PROTECTED) @Slf4j -public class PointEventListener implements StreamListener> { + +public class PointEventListener implements StreamListener>{ private final UserService userService; + private final RedisTemplate redistemplateForCluster; private final PointRepository pointRepository; + private StreamMessageListenerContainer> listenerContainer; + @Override - @CacheEvict(cacheNames = "user", key = "#message.id", cacheManager = "cacheManagerForOne") @Transactional public void onMessage(final MapRecord message) throws DateRoadException { try { + String stream = message.getStream(); + String recordId = message.getId().getValue(); Map map = message.getValue(); Long userId = Long.valueOf(map.get("userId")); TransactionType type = TransactionType.valueOf(map.get("type")); User user = userService.getUser(userId); int point = Integer.parseInt(map.get("point")); String description = map.get("description"); - int beforePoint = user.getTotalPoint(); + int beforePoint = user.getTotalPoint(); + switch (type) { case POINT_GAINED: user.setTotalPoint(user.getTotalPoint() + point); @@ -50,11 +55,15 @@ public void onMessage(final MapRecord message) throws Da default: throw new UnauthorizedException(FailureCode.INVALID_TRANSACTION_TYPE); } - pointRepository.save(Point.create(user, point, type, description)); userService.saveUser(user); - log.info("Redis onMessage[POINT]:{}:{}:BEFORE:{} => AFTER:{}", user.getId(),type.getDescription(),beforePoint,user.getTotalPoint()); + pointRepository.save(Point.create(user, point, type, description)); + redistemplateForCluster.opsForStream().acknowledge("coursePoint", "coursePointGroup", recordId); + log.info("Redis onMessage[POINT]:{}:{}:BEFORE:{} => AFTER:{}", user.getId(), type.getDescription(), beforePoint, user.getTotalPoint()); + } catch (RedisSystemException e) { + log.error("Redis Listener Error: ERROR: {}", e.getMessage()); + throw new DateRoadException(FailureCode.POINT_CREATE_ERROR); } catch (Exception e) { - log.error("redis Listener Error:ERROR: {}", e.getMessage()); + log.error("General Listener Error: ERROR: {}", e.getMessage()); throw new DateRoadException(FailureCode.POINT_CREATE_ERROR); } } diff --git a/dateroad-api/src/test/java/org/dateroad/course/service/AsyncServiceTest.java b/dateroad-api/src/test/java/org/dateroad/course/service/AsyncServiceTest.java new file mode 100644 index 00000000..15516422 --- /dev/null +++ b/dateroad-api/src/test/java/org/dateroad/course/service/AsyncServiceTest.java @@ -0,0 +1,60 @@ +package org.dateroad.course.service; + + +import java.util.HashMap; +import java.util.Map; +import org.dateroad.course.dto.request.PointUseReq; +import org.dateroad.course.service.AsyncService; +import org.dateroad.point.domain.TransactionType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StreamOperations; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AsyncServiceTest { + + @Mock + private RedisTemplate redistemplateForCluster; + @Mock + private StreamOperations streamOperations; + @InjectMocks + private AsyncService asyncService; + + @Test + void 포인트_이벤트를_보내면_Stream에_다음키값으로_다음메소드가_호출과_객체가_들어갔는지_검사() { + //given + Long userId = 1L; + PointUseReq pointUseReq = PointUseReq.of(100, TransactionType.POINT_GAINED,"point create"); + when(redistemplateForCluster.opsForStream()).thenReturn(streamOperations); + //when + asyncService.publishEvenUserPoint(userId, pointUseReq); + //then + Map expectedFieldMap = new HashMap<>(); + expectedFieldMap.put("userId", userId.toString()); + expectedFieldMap.put("point", String.valueOf(pointUseReq.getPoint())); + expectedFieldMap.put("type", pointUseReq.getType().name()); + expectedFieldMap.put("description", pointUseReq.getDescription()); + verify(redistemplateForCluster, times(1)).opsForStream(); + verify(streamOperations).add("coursePoint", expectedFieldMap); + } + + @Test + void 무료_이벤트를_보내면_Stream에_다음키값으로_다음메소드가_호출과_객체가_들어갔는지_검사() { + //given + Long userId = 1L; + when(redistemplateForCluster.opsForStream()).thenReturn(streamOperations); + //when + asyncService.publishEventUserFree(userId); + Map expectedFieldMap = new HashMap<>(); + expectedFieldMap.put("userId", userId.toString()); + //then + verify(redistemplateForCluster, times(1)).opsForStream(); + verify(streamOperations).add("courseFree", expectedFieldMap); + } +} \ No newline at end of file diff --git a/dateroad-api/src/test/java/org/dateroad/course/service/CourseServiceTest.java b/dateroad-api/src/test/java/org/dateroad/course/service/CourseServiceTest.java new file mode 100644 index 00000000..fa3cfbac --- /dev/null +++ b/dateroad-api/src/test/java/org/dateroad/course/service/CourseServiceTest.java @@ -0,0 +1,325 @@ +package org.dateroad.course.service; + +import autoparams.AutoSource; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Objects; +import java.util.Optional; +import javax.xml.validation.Validator; +import org.dateroad.code.FailureCode; +import org.dateroad.common.ValidatorUtil; +import org.dateroad.course.dto.request.CourseCreateEvent; +import org.dateroad.course.dto.request.CourseCreateReq; +import org.dateroad.course.dto.request.CourseGetAllReq; +import org.dateroad.course.dto.request.CoursePlaceGetReq; +import org.dateroad.course.dto.request.PointUseReq; +import org.dateroad.course.dto.request.TagCreateReq; +import org.dateroad.course.dto.response.CourseDtoGetRes; +import org.dateroad.course.dto.response.CourseGetAllRes; +import org.dateroad.course.service.AsyncService; +import org.dateroad.course.service.CourseService; +import org.dateroad.date.domain.Course; +import org.dateroad.date.domain.Region; +import org.dateroad.date.domain.Region.MainRegion; +import org.dateroad.date.domain.Region.SubRegion; +import org.dateroad.date.repository.CourseRepository; +import org.dateroad.dateAccess.repository.DateAccessRepository; +import org.dateroad.exception.ConflictException; +import org.dateroad.exception.EntityNotFoundException; +import org.dateroad.exception.ForbiddenException; +import org.dateroad.image.repository.ImageRepository; +import org.dateroad.like.domain.Like; +import org.dateroad.like.repository.LikeRepository; +import org.dateroad.place.repository.CoursePlaceRepository; +import org.dateroad.point.domain.Point; +import org.dateroad.point.repository.PointRepository; +import org.dateroad.tag.domain.DateTagType; +import org.dateroad.tag.domain.UserTag; +import org.dateroad.tag.repository.CourseTagRepository; +import org.dateroad.user.domain.Platform; +import org.dateroad.user.domain.User; +import org.dateroad.user.repository.UserRepository; +import org.dateroad.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataAccessException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StreamOperations; +import org.junit.jupiter.params.ParameterizedTest; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.dateroad.common.ValidatorUtil.*; +import static org.dateroad.common.ValidatorUtil.validateUserAndCourse; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class CourseServiceTest { + @Mock + private CourseRepository courseRepository; + @Mock + private UserRepository userRepository; + @Mock + private AsyncService asyncService; + @Mock + private PointRepository pointRepository; + @Mock + private CourseTagRepository courseTagRepository; + @Mock + private DateAccessRepository dateAccessRepository; + @Mock + private ImageRepository imageRepository; + @Mock + private UserService userService; + @Mock + private CoursePlaceRepository coursePlaceRepository; + @Mock + private LikeRepository likeRepository; + @Mock + ValidatorUtil valid; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private RedisTemplate redisTemplate; + @Mock + private StreamOperations streamOperations; + @InjectMocks + private CourseService courseService; + + @Test + void 코스를_생성하면_각_메소드가_호출되었는지_확인한다() { + // Given + Long userId = 1L; + CourseCreateReq courseCreateReq = CourseCreateReq.of( + "Course Title", + LocalDate.of(2024, 7, 4), + LocalTime.of(12, 30), + MainRegion.SEOUL, + SubRegion.GANGNAM_SEOCHO, + "This is a detailed description of the course.", + 100); + + List places = Arrays.asList( + CoursePlaceGetReq.of("Place 1", 1.5f, 1), + CoursePlaceGetReq.of("Place 2", 2.0f, 2) + ); + + List images = Arrays.asList( + mock(MultipartFile.class), + mock(MultipartFile.class) + ); + + List tags = Arrays.asList( + TagCreateReq.of(DateTagType.ACTIVITY), + TagCreateReq.of(DateTagType.DRIVE) + ); + + User user = mock(User.class); + when(userRepository.findUserById(userId)).thenReturn(Optional.ofNullable(user)); // 수정된 부분 + Course course = Course.create( + user, + courseCreateReq.getTitle(), + courseCreateReq.getDescription(), + courseCreateReq.getCountry(), + courseCreateReq.getCity(), + courseCreateReq.getCost(), + courseCreateReq.getDate(), + courseCreateReq.getStartAt(), + places.stream().map(CoursePlaceGetReq::getDuration).reduce(0.0f, Float::sum) + ); + when(courseRepository.save(any(Course.class))).thenReturn(course); + String expectedThumbnailUrl = "http://example.com/thumbnail.jpg"; + when(asyncService.createCourseImages(images, course)).thenReturn(expectedThumbnailUrl); + + // When + Course result = courseService.createCourse(userId, courseCreateReq, places, images, tags); + + // Then + assertNotNull(result); + assertEquals(course, result); + verify(courseRepository, times(2)).save(any(Course.class)); // Initial save and save with thumbnail + verify(asyncService).createCourseImages(images, course); + verify(eventPublisher).publishEvent(any(CourseCreateEvent.class)); + verify(asyncService).publishEvenUserPoint(eq(userId), any(PointUseReq.class)); + } + @ParameterizedTest + @AutoSource + void 서로_다른_유저일때_코스에_좋아요_생성가능(Long userId, Long courseId, User user, User anotherUser, MainRegion country, SubRegion city, int cost, + LocalDate date, LocalTime startAt, float time) { + Course course = Course.create( + anotherUser, "title", "desc", country, city, cost, date, startAt, time); + // given + when(userRepository.findUserById(userId)).thenReturn(Optional.of(user)); + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(likeRepository.findLikeByUserAndCourse(user, course)).thenReturn(Optional.empty()); + // when + courseService.createCourseLike(userId, courseId); + // then + verify(likeRepository).save(any(Like.class)); + } + + @ParameterizedTest + @AutoSource + void 유저가_만든_코스에_좋아요_접근_불가능_테스트(Long userId, Long courseId, User user, MainRegion country, SubRegion city, int cost, + LocalDate date, LocalTime startAt, float time) { + Course course = Course.create( + user, "title", "desc", country, city, cost, date, startAt, time); + // given + when(userRepository.findUserById(userId)).thenReturn(Optional.of(user)); + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + // when + // then + assertThatThrownBy(() -> courseService.createCourseLike(userId, courseId)) + .isInstanceOf(ForbiddenException.class) + .hasMessage(FailureCode.FORBIDDEN.getMessage()); + // 유저 저장 및 태그 저장 호출되지 않았는지 검증 + verify(likeRepository, never()).save(any(Like.class)); + } + + @ParameterizedTest + @AutoSource + void 같은_유저가_코스에_좋아요_중복_불가능_테스트(Long userId, Long courseId, User user, User anotherUser,MainRegion country, SubRegion city, int cost, + LocalDate date, LocalTime startAt, float time) { + Course course = Course.create( + anotherUser, "title", "desc", country, city, cost, date, startAt, time); + // givenㅇㄴㄹ + when(userRepository.findUserById(userId)).thenReturn(Optional.of(user)); + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(likeRepository.findLikeByUserAndCourse(user, course)).thenReturn(Optional.of(Like.create(course, user))); + // when + // then + assertThatThrownBy(() -> courseService.createCourseLike(userId, courseId)) + .isInstanceOf(ConflictException.class) + .hasMessage(FailureCode.DUPLICATE_COURSE_LIKE.getMessage()); + // 유저 저장 및 태그 저장 호출되지 않았는지 검증 + verify(likeRepository, never()).save(any(Like.class)); + } + + @ParameterizedTest + @AutoSource + void 코스_삭제_테스트(Long userId, Long courseId, User user, Course course, Like like) { + // given + when(userRepository.findUserById(userId)).thenReturn(Optional.of(user)); + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + when(likeRepository.findLikeByUserAndCourse(user, course)).thenReturn(Optional.of(like)); + // when + courseService.deleteCourseLike(userId, courseId); + // then + verify(likeRepository).delete(like); + } + + @ParameterizedTest + @AutoSource + void 코스_생성_테스트_오토파람(Long userId, CourseCreateReq req, List places, + List tags, User user, Course course) { + // given + List images = List.of(mock(MultipartFile.class), mock(MultipartFile.class)); // MultipartFile을 Mock으로 처리 + when(userRepository.findUserById(userId)).thenReturn(Optional.of(user)); + when(courseRepository.save(any(Course.class))).thenReturn(course); + // when + Course result = courseService.createCourse(userId, req, places, images, tags); + // then + assertNotNull(result); + verify(asyncService).createCourseImages(images, course); + verify(eventPublisher).publishEvent(any(CourseCreateEvent.class)); + verify(asyncService).publishEvenUserPoint(eq(userId), any(PointUseReq.class)); + } + + @ParameterizedTest + @AutoSource + void 코스_삭제_메소드_실행_테스트(Long userId, Long courseId, User user,MainRegion country, SubRegion city, int cost, + LocalDate date, LocalTime startAt, float time) { + Course course = Course.create( + user, "title", "desc", country, city, cost, date, startAt, time); + // given + when(userRepository.findUserById(userId)).thenReturn(Optional.of(user)); + when(courseRepository.findById(courseId)).thenReturn(Optional.of(course)); + // when + courseService.deleteCourse(userId, courseId); + // then + verify(courseRepository).deleteByCourse(courseId); + verify(coursePlaceRepository).deleteByCourse(courseId); + verify(courseTagRepository).deleteByCourse(courseId); + verify(dateAccessRepository).deleteByCourse(courseId); + verify(imageRepository).deleteByCourse(courseId); + } + + @ParameterizedTest + @AutoSource + void 코스정렬_POPULAR_테스트(List courses) { + // given + when(courseRepository.findTopCoursesByLikes(any(Pageable.class))).thenReturn(courses); + when(likeRepository.countByCourses(any())).thenReturn(new ArrayList<>()); // 좋아요 개수 처리 + // when + CourseGetAllRes result = courseService.getSortedCourses("POPULAR"); + // then + assertThat(result).isNotNull(); + verify(courseRepository,times(1)).findTopCoursesByLikes(any(Pageable.class)); // "POPULAR"일 때 좋아요 기준으로 정렬된 코스를 조회해야 함 + verify(likeRepository, times(1)).countByCourses(courses); + } + + @ParameterizedTest + @AutoSource + void 코스정렬_LATEST(List courses) { + // given + when(courseRepository.findTopCoursesByCreatedAt(any(Pageable.class))).thenReturn(courses); + when(likeRepository.countByCourses(any())).thenReturn(new ArrayList<>()); + + // when + CourseGetAllRes result = courseService.getSortedCourses("LATEST"); + + // then + assertThat(result).isNotNull(); + verify(courseRepository).findTopCoursesByCreatedAt(any(Pageable.class)); // "LATEST"일 때 최신순으로 정렬된 코스를 조회해야 함 + verify(likeRepository, times(1)).countByCourses(courses); + } + @ParameterizedTest + @AutoSource + void 정렬된코스_쿼리파람에_잘못된_값_들어왔을경우_에러반환() { + // when & then + assertThatThrownBy(() -> courseService.getSortedCourses("INVALID")) + .isInstanceOf(EntityNotFoundException.class) + .hasMessage(FailureCode.SORT_TYPE_NOT_FOUND.getMessage()); + } + + @ParameterizedTest + @AutoSource + void 정렬된_코스LIKE_반환의_사이즈가_같은지_측정(List course) { + // given + when(courseRepository.findTopCoursesByLikes(any(Pageable.class))).thenReturn(course); + // when + List result = courseService.getCoursesSortedByLikes(); + // then + assertThat(result).hasSize(course.size()); + verify(courseRepository,times(1)).findTopCoursesByLikes(any(Pageable.class)); + } + @ParameterizedTest + @AutoSource + void 정렬된_코스_생성시점_반환의_사이즈가_같은지_측정(List courses) { + // given + when(courseRepository.findTopCoursesByCreatedAt(any(Pageable.class))).thenReturn(courses); + // when + List result = courseService.getCoursesSortedByLatest(); + // then + assertThat(result).hasSize(courses.size()); + verify(courseRepository).findTopCoursesByCreatedAt(any(Pageable.class)); + } +} \ No newline at end of file diff --git a/dateroad-api/src/test/java/org/dateroad/date/repository/CourseRepositoryTest.java b/dateroad-api/src/test/java/org/dateroad/date/repository/CourseRepositoryTest.java new file mode 100644 index 00000000..52a11459 --- /dev/null +++ b/dateroad-api/src/test/java/org/dateroad/date/repository/CourseRepositoryTest.java @@ -0,0 +1,86 @@ +package org.dateroad.date.repository; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import autoparams.AutoSource; +import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import org.dateroad.date.domain.Course; +import org.dateroad.date.domain.Region.MainRegion; +import org.dateroad.date.domain.Region.SubRegion; +import org.dateroad.like.domain.Like; +import org.dateroad.like.repository.LikeRepository; +import org.dateroad.user.domain.User; +import org.dateroad.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.annotation.Rollback; + +@Rollback(value = true) +@DataJpaTest(properties = { + "spring.datasource.url=jdbc:h2:mem:testdb", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +class CourseRepositoryTest { + + @Autowired + private CourseRepository courseRepository; + @Autowired + private LikeRepository likeRepository; + + @ParameterizedTest + @AutoSource + void findTopCoursesByLikesTest(User user, MainRegion country, SubRegion city, int cost, + LocalDate date, LocalTime startAt, float time) { + // given + Course course1 = courseRepository.save(Course.create( + user, "title1", "desc", country, city, cost, date, startAt, time)); + Course course2 = courseRepository.save(Course.create( + user, "title2", "desc", country, city, cost, date, startAt, time)); + Course course3 = courseRepository.save(Course.create( + user, "title3", "desc", country, city, cost, date, startAt, time)); + + Like like1 = likeRepository.save(Like.create(course1, user)); + Like like2 = likeRepository.save(Like.create(course1, user)); + Like like3 = likeRepository.save(Like.create(course2, user)); + + + // when + Pageable pageable = PageRequest.of(0, 2); // Top 2 courses + List result = courseRepository.findTopCoursesByLikes(pageable); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getTitle()).isEqualTo(course1.getTitle()); + assertThat(result.get(1).getTitle()).isEqualTo(course2.getTitle()); + } + + @ParameterizedTest + @AutoSource + void findTopCoursesByCreatedAtTest(User user , MainRegion country, SubRegion city, int cost, + LocalDate date, LocalTime startAt, float time) { + // given + Course course1 = courseRepository.save(Course.create( + user, "title1", "desc", country, city, cost, date, startAt, time)); + Course course2 = courseRepository.save(Course.create( + user, "title2", "desc", country, city, cost, date, startAt, time)); + Course course3 = courseRepository.save(Course.create( + user, "title3", "desc", country, city, cost, date, startAt, time)); + // when + Pageable pageable = PageRequest.of(0, 2); // Top 2 latest courses + List result = courseRepository.findTopCoursesByCreatedAt(pageable); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getTitle()).isEqualTo(course3.getTitle()); // Course 3 is the latest + assertThat(result.get(1).getTitle()).isEqualTo(course2.getTitle()); // Course 2 is second latest + } +} \ No newline at end of file diff --git a/dateroad-api/src/test/java/org/dateroad/date/service/DateServiceTest.java b/dateroad-api/src/test/java/org/dateroad/date/service/DateServiceTest.java index 56dcf232..8131ddb9 100644 --- a/dateroad-api/src/test/java/org/dateroad/date/service/DateServiceTest.java +++ b/dateroad-api/src/test/java/org/dateroad/date/service/DateServiceTest.java @@ -41,7 +41,6 @@ class DateServiceTest { DateService dateService; @Test - @DisplayName("잘 생성된다") void dateCreate() { // given User user = User.create("가든잉", "platformUserId123", Platform.KAKAO, "imageUrl"); diff --git a/dateroad-api/src/test/java/org/dateroad/point/event/PointEventListenerTest.java b/dateroad-api/src/test/java/org/dateroad/point/event/PointEventListenerTest.java new file mode 100644 index 00000000..2bd06033 --- /dev/null +++ b/dateroad-api/src/test/java/org/dateroad/point/event/PointEventListenerTest.java @@ -0,0 +1,60 @@ +package org.dateroad.point.event; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import org.dateroad.exception.DateRoadException; +import org.dateroad.point.domain.Point; +import org.dateroad.point.repository.PointRepository; +import org.dateroad.user.domain.User; +import org.dateroad.user.service.UserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.stream.MapRecord; + +@ExtendWith(MockitoExtension.class) +class PointEventListenerTest { + + @Mock + private UserService userService; + + @Mock + private PointRepository pointRepository; + + @InjectMocks + private PointEventListener pointEventListener; + + @Test + void 이벤트를_받으면_User의_포인트증가와_메소드가실행되었는지_검사한다() throws DateRoadException { + // Given + Map messageMap = Map.of( + "userId", "39", + "point", "100", + "type", "POINT_GAINED", + "description", "Course creation" + ); + + MapRecord record = MapRecord.create("coursePoint", messageMap); + + User user = mock(User.class); + when(userService.getUser(39L)).thenReturn(user); + when(user.getTotalPoint()).thenReturn(100); + + // When + pointEventListener.onMessage(record); + + // Then + verify(userService).getUser(39L); + verify(user).setTotalPoint(200); // Assuming POINT_GAINED adds the point + verify(pointRepository,times(1)).save(any(Point.class)); + verify(userService).saveUser(user); + } +} \ No newline at end of file diff --git a/dateroad-api/src/test/java/org/dateroad/user/service/AuthServiceTest.java b/dateroad-api/src/test/java/org/dateroad/user/service/AuthServiceTest.java index bc039885..9ac78376 100644 --- a/dateroad-api/src/test/java/org/dateroad/user/service/AuthServiceTest.java +++ b/dateroad-api/src/test/java/org/dateroad/user/service/AuthServiceTest.java @@ -1,9 +1,34 @@ package org.dateroad.user.service; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; import org.assertj.core.api.Assertions; +import org.dateroad.auth.jwt.JwtProvider; +import org.dateroad.auth.jwt.Token; +import org.dateroad.code.FailureCode; +import org.dateroad.event.SignUpEventInfo; import org.dateroad.exception.ConflictException; +import org.dateroad.feign.apple.AppleFeignProvider; +import org.dateroad.feign.discord.DiscordFeignProvider; +import org.dateroad.feign.kakao.KakaoFeignProvider; +import org.dateroad.image.service.ImageService; +import org.dateroad.refreshtoken.repository.RefreshTokenRepository; +import org.dateroad.tag.domain.DateTagType; +import org.dateroad.tag.domain.UserTag; +import org.dateroad.tag.repository.UserTagRepository; import org.dateroad.user.domain.Platform; import org.dateroad.user.domain.User; +import org.dateroad.user.dto.request.UserSignUpReq; +import org.dateroad.user.dto.response.UserJwtInfoRes; import org.dateroad.user.repository.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,28 +36,101 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; @ExtendWith(MockitoExtension.class) class AuthServiceTest { + @InjectMocks - AuthService authService; + private AuthService authService; + + @Mock + private UserRepository userRepository; + + @Mock + private JwtProvider jwtProvider; + + @Mock + private KakaoFeignProvider kakaoFeignProvider; + + @Mock + private AppleFeignProvider appleFeignProvider; + + @Mock + private UserTagRepository userTagRepository; + + @Mock + private ImageService imageService; + + @Mock + private DiscordFeignProvider discordFeignProvider; @Mock - UserRepository userRepository; + private RefreshTokenRepository refreshTokenRepository; + // 회원가입 테스트 @Test - @DisplayName("유저가 회원가입을 한다.") - void testDuplicatedNickName() { + @DisplayName("회원가입 성공 테스트") + void testSignUp_Success() { + // given + String token = "testToken"; + MultipartFile image = null; // 이미지 업로드 테스트는 생략 + List tags = List.of(DateTagType.DRIVE); // 태그 리스트 + String platformUserId = "platformUserId"; + String nickname = "성준"; + + UserSignUpReq userSignUpReq = new UserSignUpReq(nickname, Platform.KAKAO); // 회원가입 요청 객체 + + when(userRepository.existsByName(nickname)).thenReturn(false); // 닉네임 중복 체크 + when(kakaoFeignProvider.getKakaoPlatformUserId(token)).thenReturn(platformUserId); // 카카오 플랫폼 유저 ID 조회 + when(userRepository.existsUserByPlatFormAndPlatformUserId(Platform.KAKAO, platformUserId)).thenReturn(false); // 중복 유저 확인 + when(imageService.getImageUrl(image)).thenReturn("imageUrl"); // 이미지 처리 + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + // 유저 ID를 수동으로 설정 (테스트 환경에서는 DB가 없으므로) + user.setId(1L); + return user; + });// 유저 저장 + when(jwtProvider.issueToken(anyLong())).thenReturn(new Token("accessToken", "refreshToken")); // 토큰 발급 + // when + UserJwtInfoRes result = authService.signUp(token, userSignUpReq, image, tags); + + // then + assertThat(result).isNotNull(); + assertThat(result.accessToken()).isEqualTo("accessToken"); + assertThat(result.refreshToken()).isEqualTo("refreshToken"); + + // Discord webhook 전송 확인 + verify(discordFeignProvider).sendSignUpInfoToDiscord(any(SignUpEventInfo.class)); + + // 유저 저장 여부 확인 + verify(userRepository).save(any(User.class)); + + // 태그 저장 여부 확인 + verify(userTagRepository, times(tags.size())).save(any(UserTag.class)); + } + + // 닉네임 중복으로 인한 회원가입 실패 테스트 + @Test + @DisplayName("중복 닉네임으로 회원가입 시 ConflictException 발생") + void testSignUp_DuplicateNickname() { + // given + String token = "testToken"; + MultipartFile image = null; + List tags = List.of(DateTagType.DRIVE); + String nickname = "성준"; + + UserSignUpReq userSignUpReq = new UserSignUpReq(nickname, Platform.KAKAO); - //given - User user = User.create("성준", "dfsjalsadf", Platform.KAKAO, "null"); + when(userRepository.existsByName(nickname)).thenReturn(true); // 닉네임이 이미 존재한다고 설정 - //when - userRepository.save(user); + // when & then + assertThatThrownBy(() -> authService.signUp(token, userSignUpReq, image, tags)) + .isInstanceOf(ConflictException.class) + .hasMessage(FailureCode.DUPLICATE_NICKNAME.getMessage()); - //then - Assertions.assertThatThrownBy( - () -> authService.checkNickname("성준") - ).isInstanceOf(ConflictException.class); + // 유저 저장 및 태그 저장 호출되지 않았는지 검증 + verify(userRepository, never()).save(any(User.class)); + verify(userTagRepository, never()).save(any(UserTag.class)); } -} \ No newline at end of file +} diff --git a/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java b/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java index 05f9e394..9108ea36 100644 --- a/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java +++ b/dateroad-common/src/main/java/org/dateroad/code/FailureCode.java @@ -99,7 +99,8 @@ public enum FailureCode { */ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "e5000", "서버 내부 오류입니다."), COURSE_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"e5001" , "코스 생성에 실패했습니다."), - POINT_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "e5002", "포인트 생성에 실패했습니다"); + POINT_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "e5002", "포인트 생성에 실패했습니다"), + REDIS_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "e5003", "Redis 연결에 실패했습니다"); private final HttpStatus httpStatus; diff --git a/dateroad-domain/src/main/java/org/dateroad/date/domain/Course.java b/dateroad-domain/src/main/java/org/dateroad/date/domain/Course.java index ed884acb..fc24ddba 100644 --- a/dateroad-domain/src/main/java/org/dateroad/date/domain/Course.java +++ b/dateroad-domain/src/main/java/org/dateroad/date/domain/Course.java @@ -23,7 +23,7 @@ @Getter @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor @EntityListeners(AuditingEntityListener.class) @Table(name = "courses") public class Course extends DateBase { diff --git a/dateroad-domain/src/main/java/org/dateroad/like/domain/Like.java b/dateroad-domain/src/main/java/org/dateroad/like/domain/Like.java index d5a40a2f..e487034e 100644 --- a/dateroad-domain/src/main/java/org/dateroad/like/domain/Like.java +++ b/dateroad-domain/src/main/java/org/dateroad/like/domain/Like.java @@ -20,7 +20,7 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor @Builder(access = AccessLevel.PRIVATE) @Table(name = "likes") public class Like extends BaseTimeEntity { diff --git a/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java b/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java index 4eb49b36..395e5ded 100644 --- a/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java +++ b/dateroad-domain/src/main/java/org/dateroad/user/domain/User.java @@ -11,7 +11,7 @@ import org.dateroad.common.BaseTimeEntity; @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor @Builder(access = AccessLevel.PRIVATE) @Getter @Setter