diff --git a/build.gradle b/build.gradle index f02b03f6..764a0abe 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -53,6 +54,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.batch:spring-batch-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:junit-jupiter' diff --git a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java index b2d4421b..429b1363 100644 --- a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java @@ -1,41 +1,37 @@ package com.codeit.team5.mopl.config; -import com.codeit.team5.mopl.global.async.CompositeTaskDecorator; import com.codeit.team5.mopl.global.async.MdcTaskDecorator; -import com.codeit.team5.mopl.global.async.SecurityContextTaskDecorator; -import java.util.List; -import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration -@EnableAsync public class AsyncConfig { - @Bean(name = "binaryContentUploadExecutor") - public Executor binaryContentUploadExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(2); - executor.setMaxPoolSize(5); - executor.setQueueCapacity(100); - executor.setThreadNamePrefix("binary-upload-"); - executor.setTaskDecorator(new CompositeTaskDecorator( - List.of(new MdcTaskDecorator(), new SecurityContextTaskDecorator()))); - executor.initialize(); - return executor; - } + private static final int CORE_POOL_SIZE = 2; + private static final int MAX_POOL_SIZE = 4; + private static final int QUEUE_CAPACITY = 10; - @Bean(name = "contentCollectionExecutor") - public Executor contentCollectionExecutor() { + /** + * 배치 Job을 비동기로 실행하기 위한 스레드 풀. + *

+ * 동시성 상한을 두어 수집 요청이 몰려도 서버 자원이 무한정 잠식되지 않도록 한다. + * 큐까지 가득 차면 AbortPolicy로 요청을 거부하며, 이때 발생하는 TaskRejectedException은 + * GlobalExceptionHandler에서 503으로 매핑된다. + *

+ * MDC(requestId 등)만 복사한다. 콘텐츠 수집은 스케줄러로도 실행되는 시스템 작업이라 + * 인증 컨텍스트에 의존하지 않으므로 SecurityContext는 복사하지 않는다. + */ + @Bean + public ThreadPoolTaskExecutor batchJobTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(1); - executor.setMaxPoolSize(2); - executor.setQueueCapacity(10); - executor.setThreadNamePrefix("content-collect-"); - executor.setTaskDecorator(new CompositeTaskDecorator( - List.of(new MdcTaskDecorator(), new SecurityContextTaskDecorator()))); + executor.setCorePoolSize(CORE_POOL_SIZE); + executor.setMaxPoolSize(MAX_POOL_SIZE); + executor.setQueueCapacity(QUEUE_CAPACITY); + executor.setThreadNamePrefix("batch-job-"); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; } diff --git a/src/main/java/com/codeit/team5/mopl/config/BatchConfig.java b/src/main/java/com/codeit/team5/mopl/config/BatchConfig.java new file mode 100644 index 00000000..69d7db48 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/config/BatchConfig.java @@ -0,0 +1,228 @@ +package com.codeit.team5.mopl.config; + +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.batch.processor.SportsDbEventItemProcessor; +import com.codeit.team5.mopl.content.batch.processor.TmdbMovieItemProcessor; +import com.codeit.team5.mopl.content.batch.processor.TmdbTvSeriesItemProcessor; +import com.codeit.team5.mopl.content.batch.reader.SportsDbDayItemReader; +import com.codeit.team5.mopl.content.batch.reader.SportsDbEventItemReader; +import com.codeit.team5.mopl.content.batch.reader.TmdbMovieItemReader; +import com.codeit.team5.mopl.content.batch.reader.TmdbTvSeriesItemReader; +import com.codeit.team5.mopl.content.batch.retry.LoggingSkipListener; +import com.codeit.team5.mopl.content.batch.retry.SelectiveRetryPolicy; +import com.codeit.team5.mopl.content.batch.retry.SelectiveSkipPolicy; +import com.codeit.team5.mopl.content.batch.writer.ContentItemWriter; +import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient; +import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.repository.ContentStatsRepository; +import com.codeit.team5.mopl.tag.repository.TagRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.launch.support.TaskExecutorJobLauncher; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class BatchConfig { + + private static final int CHUNK_SIZE = 100; + private static final int RETRY_LIMIT = 3; + private static final int TMDB_SKIP_LIMIT = 10; + private static final int SPORTS_DB_SKIP_LIMIT = 3; + private static final int EXTERNAL_FETCH_MAX_ATTEMPTS = 2; + private static final long EXTERNAL_FETCH_BACKOFF_MS = 200; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final TmdbApiClient tmdbApiClient; + private final SportsDbApiClient sportsDbApiClient; + private final ContentRepository contentRepository; + private final ContentStatsRepository contentStatsRepository; + private final BinaryContentRepository binaryContentRepository; + private final TagRepository tagRepository; + private final ObjectMapper objectMapper; + + // ── 비동기 JobLauncher ─────────────────────────────── + + @Bean + public JobLauncher asyncJobLauncher(JobRepository jobRepository, + ThreadPoolTaskExecutor batchJobTaskExecutor) throws Exception { + TaskExecutorJobLauncher launcher = new TaskExecutorJobLauncher(); + launcher.setJobRepository(jobRepository); + launcher.setTaskExecutor(batchJobTaskExecutor); + launcher.afterPropertiesSet(); + return launcher; + } + + // ── TMDB 영화 Job ────────────────────────────────────────────── + + @Bean + public Job tmdbMovieJob() { + return new JobBuilder("tmdbMovieJob", jobRepository) + .start(tmdbMovieStep()) + .build(); + } + + @Bean + public Step tmdbMovieStep() { + return new StepBuilder("tmdbMovieStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(tmdbMovieItemReader()) + .processor(tmdbMovieItemProcessor()) + .writer(contentItemWriter()) + .faultTolerant() + .retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT)) + .skipPolicy(new SelectiveSkipPolicy(TMDB_SKIP_LIMIT)) + .listener(new LoggingSkipListener()) + .build(); + } + + @Bean + @StepScope + public TmdbMovieItemReader tmdbMovieItemReader() { + return new TmdbMovieItemReader(tmdbApiClient, externalFetchRetryTemplate()); + } + + @Bean + public TmdbMovieItemProcessor tmdbMovieItemProcessor() { + return new TmdbMovieItemProcessor(contentRepository, objectMapper); + } + + // ── TMDB TV 시리즈 Job ───────────────────────────────────────── + + @Bean + public Job tmdbTvSeriesJob() { + return new JobBuilder("tmdbTvSeriesJob", jobRepository) + .start(tmdbTvSeriesStep()) + .build(); + } + + @Bean + public Step tmdbTvSeriesStep() { + return new StepBuilder("tmdbTvSeriesStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(tmdbTvSeriesItemReader()) + .processor(tmdbTvSeriesItemProcessor()) + .writer(contentItemWriter()) + .faultTolerant() + .retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT)) + .skipPolicy(new SelectiveSkipPolicy(TMDB_SKIP_LIMIT)) + .listener(new LoggingSkipListener()) + .build(); + } + + @Bean + @StepScope + public TmdbTvSeriesItemReader tmdbTvSeriesItemReader() { + return new TmdbTvSeriesItemReader(tmdbApiClient, externalFetchRetryTemplate()); + } + + @Bean + public TmdbTvSeriesItemProcessor tmdbTvSeriesItemProcessor() { + return new TmdbTvSeriesItemProcessor(contentRepository, objectMapper); + } + + // ── SportsDB 리그-시즌 Job ─────────────────────────────────────────────── + + @Bean + public Job sportsDbEventJob() { + return new JobBuilder("sportsDbEventJob", jobRepository) + .start(sportsDbEventStep()) + .build(); + } + + @Bean + public Step sportsDbEventStep() { + return new StepBuilder("sportsDbEventStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sportsDbEventItemReader()) + .processor(sportsDbEventItemProcessor()) + .writer(contentItemWriter()) + .faultTolerant() + .retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT)) + .skipPolicy(new SelectiveSkipPolicy(SPORTS_DB_SKIP_LIMIT)) + .listener(new LoggingSkipListener()) + .build(); + } + + @Bean + @StepScope + public SportsDbEventItemReader sportsDbEventItemReader() { + return new SportsDbEventItemReader(sportsDbApiClient, externalFetchRetryTemplate()); + } + + @Bean + public SportsDbEventItemProcessor sportsDbEventItemProcessor() { + return new SportsDbEventItemProcessor(contentRepository, objectMapper); + } + + // ── SportsDB 일별 Job ────────────────────────────────────────── + + @Bean + public Job sportsDbDayJob() { + return new JobBuilder("sportsDbDayJob", jobRepository) + .start(sportsDbDayStep()) + .build(); + } + + @Bean + public Step sportsDbDayStep() { + return new StepBuilder("sportsDbDayStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(sportsDbDayItemReader()) + .processor(sportsDbEventItemProcessor()) + .writer(contentItemWriter()) + .faultTolerant() + .retryPolicy(new SelectiveRetryPolicy(RETRY_LIMIT)) + .skipPolicy(new SelectiveSkipPolicy(SPORTS_DB_SKIP_LIMIT)) + .listener(new LoggingSkipListener()) + .build(); + } + + @Bean + @StepScope + public SportsDbDayItemReader sportsDbDayItemReader() { + return new SportsDbDayItemReader(sportsDbApiClient, externalFetchRetryTemplate()); + } + + // @BeforeStep에서 여러 건을 순회 조회하는 Reader(TMDB 영화/TV, SportsDB)가 공용으로 쓰는 + // 재시도 정책 (최대 2회 시도, 200ms 고정 백오프). @BeforeStep은 청크 단위 faultTolerant 재시도의 + // 보호 범위 밖이라, 한 건의 일시적 오류로 전체 수집이 실패해 이미 모은 데이터까지 유실되는 것을 + // 막기 위해 재시도해도 성공 가능성이 있는 오류만 재시도한다. + @Bean + public RetryTemplate externalFetchRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setRetryPolicy(new SelectiveRetryPolicy(EXTERNAL_FETCH_MAX_ATTEMPTS)); + + FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(); + backOffPolicy.setBackOffPeriod(EXTERNAL_FETCH_BACKOFF_MS); + retryTemplate.setBackOffPolicy(backOffPolicy); + + return retryTemplate; + } + + // ── 공통 Writer ──────────────────────────────────────────────── + + @Bean + public ContentItemWriter contentItemWriter() { + return new ContentItemWriter(contentRepository, contentStatsRepository, + binaryContentRepository, tagRepository); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/config/ScheduleConfig.java b/src/main/java/com/codeit/team5/mopl/config/ScheduleConfig.java new file mode 100644 index 00000000..ce1a12ae --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/config/ScheduleConfig.java @@ -0,0 +1,9 @@ +package com.codeit.team5.mopl.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class ScheduleConfig { +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/dto/ContentWithMetaData.java b/src/main/java/com/codeit/team5/mopl/content/batch/dto/ContentWithMetaData.java new file mode 100644 index 00000000..4f762597 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/dto/ContentWithMetaData.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.content.batch.dto; + +import com.codeit.team5.mopl.content.entity.Content; +import java.util.List; + +public record ContentWithMetaData( + Content content, + String thumbnailUrl, + List tagNames +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/exception/BatchJobLaunchException.java b/src/main/java/com/codeit/team5/mopl/content/batch/exception/BatchJobLaunchException.java new file mode 100644 index 00000000..c0251142 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/exception/BatchJobLaunchException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.content.batch.exception; + +import com.codeit.team5.mopl.content.exception.ContentException; +import org.springframework.http.HttpStatus; + +public class BatchJobLaunchException extends ContentException { + + public BatchJobLaunchException(String jobName, Throwable cause) { + super(HttpStatus.INTERNAL_SERVER_ERROR, "배치 작업(" + jobName + ") 실행에 실패했습니다."); + initCause(cause); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessor.java b/src/main/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessor.java new file mode 100644 index 00000000..df5298cf --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessor.java @@ -0,0 +1,69 @@ +package com.codeit.team5.mopl.content.batch.processor; + +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.util.StringUtils; + +@Slf4j +@RequiredArgsConstructor +public class SportsDbEventItemProcessor implements ItemProcessor { + + private final ContentRepository contentRepository; + private final ObjectMapper objectMapper; + + @Override + public ContentWithMetaData process(SportsDbEventDto dto) { + if (contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, dto.idEvent())) { + log.debug("[SportsDB] 경기 스킵 (이미 존재) - idEvent={}, event={}", dto.idEvent(), dto.strEvent()); + return null; + } + + Content content = Content.createByExternalSource( + ContentType.SPORT, + dto.strEvent(), + buildDescription(dto), + ContentSource.SPORTS_DB, + dto.idEvent(), + ContentCollectionUtils.parseDate(dto.dateEvent(), "SportsDB"), + buildMetadata(dto) + ); + + String leagueName = StringUtils.hasText(dto.strLeague()) + ? dto.strLeague().trim().toLowerCase() + : null; + + return new ContentWithMetaData(content, dto.strThumb(), leagueName != null ? List.of(leagueName) : List.of()); + } + + private String buildDescription(SportsDbEventDto dto) { + String score = StringUtils.hasText(dto.intHomeScore()) && StringUtils.hasText(dto.intAwayScore()) + ? dto.intHomeScore() + " - " + dto.intAwayScore() + : "vs"; + return String.format("%s | %s %s %s", dto.strLeague(), dto.strHomeTeam(), score, dto.strAwayTeam()); + } + + private String buildMetadata(SportsDbEventDto dto) { + try { + Map metadata = new HashMap<>(); + metadata.put("sport", dto.strSport()); + metadata.put("league", dto.strLeague()); + return objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[SportsDB] metadata 직렬화 실패 - idEvent={}", dto.idEvent()); + return "{}"; + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessor.java b/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessor.java new file mode 100644 index 00000000..76b2fe55 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessor.java @@ -0,0 +1,71 @@ +package com.codeit.team5.mopl.content.batch.processor; + +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieGenre; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +@Slf4j +@RequiredArgsConstructor +public class TmdbMovieItemProcessor implements ItemProcessor { + + private static final String TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; + + private final ContentRepository contentRepository; + private final ObjectMapper objectMapper; + + @Override + public ContentWithMetaData process(TmdbMovieDto dto) { + if (contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, String.valueOf(dto.id()))) { + log.debug("[TMDB] 영화 스킵 (이미 존재) - externalId={}, title={}", dto.id(), dto.title()); + return null; + } + + Content content = Content.createByExternalSource( + ContentType.MOVIE, + dto.title(), + dto.overview(), + ContentSource.TMDB, + String.valueOf(dto.id()), + ContentCollectionUtils.parseDate(dto.releaseDate(), "TMDB"), + buildMetadata(dto) + ); + + String thumbnailUrl = dto.posterPath() != null ? TMDB_IMAGE_BASE_URL + dto.posterPath() : null; + List tagNames = resolveTagNames(dto); + + return new ContentWithMetaData(content, thumbnailUrl, tagNames); + } + + private List resolveTagNames(TmdbMovieDto dto) { + return dto.genreIds().stream() + .map(id -> TmdbMovieGenre.fromId(id).map(TmdbMovieGenre::getLabel)) + .filter(java.util.Optional::isPresent) + .map(java.util.Optional::get) + .toList(); + } + + private String buildMetadata(TmdbMovieDto dto) { + try { + Map metadata = new HashMap<>(); + metadata.put("voteAverage", dto.voteAverage()); + metadata.put("originalLanguage", dto.originalLanguage()); + return objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[TMDB] metadata 직렬화 실패 - externalId={}", dto.id()); + return "{}"; + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessor.java b/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessor.java new file mode 100644 index 00000000..b140368a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessor.java @@ -0,0 +1,71 @@ +package com.codeit.team5.mopl.content.batch.processor; + +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvGenre; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +@Slf4j +@RequiredArgsConstructor +public class TmdbTvSeriesItemProcessor implements ItemProcessor { + + private static final String TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; + + private final ContentRepository contentRepository; + private final ObjectMapper objectMapper; + + @Override + public ContentWithMetaData process(TmdbTvDto dto) { + if (contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, String.valueOf(dto.id()))) { + log.debug("[TMDB] TV 시리즈 스킵 (이미 존재) - externalId={}, title={}", dto.id(), dto.name()); + return null; + } + + Content content = Content.createByExternalSource( + ContentType.TV_SERIES, + dto.name(), + dto.overview(), + ContentSource.TMDB, + String.valueOf(dto.id()), + ContentCollectionUtils.parseDate(dto.firstAirDate(), "TMDB"), + buildMetadata(dto) + ); + + String thumbnailUrl = dto.posterPath() != null ? TMDB_IMAGE_BASE_URL + dto.posterPath() : null; + List tagNames = resolveTagNames(dto); + + return new ContentWithMetaData(content, thumbnailUrl, tagNames); + } + + private List resolveTagNames(TmdbTvDto dto) { + return dto.genreIds().stream() + .map(id -> TmdbTvGenre.fromId(id).map(TmdbTvGenre::getLabel)) + .filter(java.util.Optional::isPresent) + .map(java.util.Optional::get) + .toList(); + } + + private String buildMetadata(TmdbTvDto dto) { + try { + Map metadata = new HashMap<>(); + metadata.put("voteAverage", dto.voteAverage()); + metadata.put("originalLanguage", dto.originalLanguage()); + return objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[TMDB] metadata 직렬화 실패 - externalId={}", dto.id()); + return "{}"; + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/reader/SportsDbDayItemReader.java b/src/main/java/com/codeit/team5/mopl/content/batch/reader/SportsDbDayItemReader.java new file mode 100644 index 00000000..7209f04c --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/reader/SportsDbDayItemReader.java @@ -0,0 +1,64 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventListResponse; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbLeague; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ItemReader; +import org.springframework.retry.support.RetryTemplate; + +@Slf4j +@RequiredArgsConstructor +public class SportsDbDayItemReader implements ItemReader { + + private final SportsDbApiClient sportsDbApiClient; + private final RetryTemplate retryTemplate; + + private final Queue buffer = new LinkedList<>(); + + @BeforeStep + public void beforeStep(StepExecution stepExecution) { + String date = stepExecution.getJobParameters().getString("date"); + if (date == null) { + throw new IllegalArgumentException("date 파라미터는 필수입니다."); + } + + for (SportsDbLeague league : SportsDbLeague.values()) { + fetchLeagueEvents(league, date); + } + + log.info("[SportsDB] 일별 수집 완료 - 총 {}건", buffer.size()); + } + + // 리그 하나의 조회 실패(네트워크 오류 등)가 전체 Step 실패로 번져 이미 수집한 + // 다른 리그 데이터까지 유실되지 않도록, 재시도 후에도 실패하면 해당 리그만 건너뛴다. + private void fetchLeagueEvents(SportsDbLeague league, String date) { + try { + SportsDbEventListResponse response = retryTemplate.execute(context -> + sportsDbApiClient.fetchEventsByDay(league.getLeagueId(), date)); + + if (response == null || response.events() == null) { + log.debug("[SportsDB] 경기 없음 - league={}, date={}", league.getName(), date); + return; + } + List events = response.events(); + buffer.addAll(events); + log.info("[SportsDB] league={}, date={} - {}건 로드", league.getName(), date, events.size()); + } catch (Exception e) { + log.warn("[SportsDB] league={} 조회 실패 (재시도 소진), 건너뜀 - date={}, error={}", + league.getName(), date, e.getMessage()); + } + } + + @Override + public SportsDbEventDto read() { + return buffer.poll(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/reader/SportsDbEventItemReader.java b/src/main/java/com/codeit/team5/mopl/content/batch/reader/SportsDbEventItemReader.java new file mode 100644 index 00000000..f2d0b632 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/reader/SportsDbEventItemReader.java @@ -0,0 +1,49 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventListResponse; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ItemReader; +import org.springframework.retry.support.RetryTemplate; + +@Slf4j +@RequiredArgsConstructor +public class SportsDbEventItemReader implements ItemReader { + + private final SportsDbApiClient sportsDbApiClient; + private final RetryTemplate retryTemplate; + + private final Queue buffer = new LinkedList<>(); + + @BeforeStep + public void beforeStep(StepExecution stepExecution) { + String leagueId = stepExecution.getJobParameters().getString("leagueId"); + String season = stepExecution.getJobParameters().getString("season"); + if (leagueId == null || season == null) { + throw new IllegalArgumentException("leagueId, season 파라미터는 필수입니다."); + } + + SportsDbEventListResponse response = retryTemplate.execute(context -> + sportsDbApiClient.fetchEventsBySeason(leagueId, season)); + + if (response == null || response.events() == null) { + log.warn("[SportsDB] 수집된 경기 없음 - leagueId={}, season={}", leagueId, season); + } else { + List events = response.events(); + buffer.addAll(events); + log.info("[SportsDB] leagueId={}, season={} - {}건 로드 완료", leagueId, season, events.size()); + } + } + + @Override + public SportsDbEventDto read() { + return buffer.poll(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/reader/TmdbMovieItemReader.java b/src/main/java/com/codeit/team5/mopl/content/batch/reader/TmdbMovieItemReader.java new file mode 100644 index 00000000..5a994de3 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/reader/TmdbMovieItemReader.java @@ -0,0 +1,75 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieListResponse; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ItemReader; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.StringUtils; + +@Slf4j +@RequiredArgsConstructor +public class TmdbMovieItemReader implements ItemReader { + + private static final long REQUEST_DELAY_MS = 300; + private static final int MAX_PAGE = 500; + + private final TmdbApiClient tmdbApiClient; + private final RetryTemplate retryTemplate; + + private final Queue buffer = new LinkedList<>(); + + @BeforeStep + public void beforeStep(StepExecution stepExecution) throws InterruptedException { + String startPageParam = stepExecution.getJobParameters().getString("startPage"); + String endPageParam = stepExecution.getJobParameters().getString("endPage"); + if (startPageParam == null || endPageParam == null) { + throw new IllegalArgumentException("startPage, endPage 파라미터는 필수입니다."); + } + int currentPage = Integer.parseInt(startPageParam); + int endPage = Math.min(Integer.parseInt(endPageParam), MAX_PAGE); + buffer.clear(); + log.info("[TMDB] 영화 수집 범위 설정 - {}~{}페이지", currentPage, endPage); + + int totalPages = Integer.MAX_VALUE; + while (currentPage <= Math.min(totalPages, endPage)) { + int page = currentPage; + try { + TmdbMovieListResponse response = retryTemplate.execute(context -> + tmdbApiClient.fetchMovies(page)); + totalPages = response.totalPages(); + + List results = response.results(); + if (results.isEmpty()) { + log.info("[TMDB] 영화 {}페이지 응답이 비어 있어 수집을 종료합니다 - 전체 {}페이지", page, totalPages); + break; + } + + results.stream() + .filter(dto -> StringUtils.hasText(dto.title())) + .forEach(buffer::add); + log.info("[TMDB] 영화 {}페이지 로드 완료 - 누적 {}건 (전체 {}페이지)", page, buffer.size(), totalPages); + } catch (Exception e) { + // 재시도까지 소진된 페이지 하나 때문에 이미 모은 데이터까지 버리지 않도록, + // 이 페이지만 건너뛰고 다음 페이지로 계속 진행한다. + log.warn("[TMDB] 영화 {}페이지 조회 실패 (재시도 소진), 건너뜀 - error={}", page, e.getMessage()); + } + currentPage++; + Thread.sleep(REQUEST_DELAY_MS); + } + + log.info("[TMDB] 영화 수집 완료 - 총 {}건", buffer.size()); + } + + @Override + public TmdbMovieDto read() { + return buffer.poll(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/reader/TmdbTvSeriesItemReader.java b/src/main/java/com/codeit/team5/mopl/content/batch/reader/TmdbTvSeriesItemReader.java new file mode 100644 index 00000000..b0f7c7e2 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/reader/TmdbTvSeriesItemReader.java @@ -0,0 +1,75 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvListResponse; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ItemReader; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.StringUtils; + +@Slf4j +@RequiredArgsConstructor +public class TmdbTvSeriesItemReader implements ItemReader { + + private static final long REQUEST_DELAY_MS = 300; + private static final int MAX_PAGE = 500; + + private final TmdbApiClient tmdbApiClient; + private final RetryTemplate retryTemplate; + + private final Queue buffer = new LinkedList<>(); + + @BeforeStep + public void beforeStep(StepExecution stepExecution) throws InterruptedException { + String startPageParam = stepExecution.getJobParameters().getString("startPage"); + String endPageParam = stepExecution.getJobParameters().getString("endPage"); + if (startPageParam == null || endPageParam == null) { + throw new IllegalArgumentException("startPage, endPage 파라미터는 필수입니다."); + } + int currentPage = Integer.parseInt(startPageParam); + int endPage = Math.min(Integer.parseInt(endPageParam), MAX_PAGE); + buffer.clear(); + log.info("[TMDB] TV 시리즈 수집 범위 설정 - {}~{}페이지", currentPage, endPage); + + int totalPages = Integer.MAX_VALUE; + while (currentPage <= Math.min(totalPages, endPage)) { + int page = currentPage; + try { + TmdbTvListResponse response = retryTemplate.execute(context -> + tmdbApiClient.fetchTvSeries(page)); + totalPages = response.totalPages(); + + List results = response.results(); + if (results.isEmpty()) { + log.info("[TMDB] TV 시리즈 {}페이지 응답이 비어 있어 수집을 종료합니다 - 전체 {}페이지", page, totalPages); + break; + } + + results.stream() + .filter(dto -> StringUtils.hasText(dto.name())) + .forEach(buffer::add); + log.info("[TMDB] TV 시리즈 {}페이지 로드 완료 - 누적 {}건 (전체 {}페이지)", page, buffer.size(), totalPages); + } catch (Exception e) { + // 재시도까지 소진된 페이지 하나 때문에 이미 모은 데이터까지 버리지 않도록, + // 이 페이지만 건너뛰고 다음 페이지로 계속 진행한다. + log.warn("[TMDB] TV 시리즈 {}페이지 조회 실패 (재시도 소진), 건너뜀 - error={}", page, e.getMessage()); + } + currentPage++; + Thread.sleep(REQUEST_DELAY_MS); + } + + log.info("[TMDB] TV 시리즈 수집 완료 - 총 {}건", buffer.size()); + } + + @Override + public TmdbTvDto read() { + return buffer.poll(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/retry/ExternalFailureClassifier.java b/src/main/java/com/codeit/team5/mopl/content/batch/retry/ExternalFailureClassifier.java new file mode 100644 index 00000000..41132e70 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/retry/ExternalFailureClassifier.java @@ -0,0 +1,39 @@ +package com.codeit.team5.mopl.content.batch.retry; + +import org.springframework.dao.TransientDataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +/** + * 외부 API/DB 호출 실패가 재시도해서 성공할 가능성이 있는(=시스템 전역/일시적) 문제인지 판단한다. + *

+ * 이 판단은 재시도 정책({@link SelectiveRetryPolicy})과 스킵 정책(SelectiveSkipPolicy) 양쪽에서 + * 공유된다 — 재시도해도 가망 없는 오류를 스킵 대상으로, 재시도까지 실패한 시스템 전역 문제는 + * 스킵하지 않고 Step을 즉시 실패시키기 위해서다. + *

+ * - 네트워크 자체 실패(WebClientRequestException): 항상 해당 (응답을 못 받았으므로 서버 상태 판단 불가) + * - DB 일시 오류(TransientDataAccessException): 항상 해당 + * - 5xx 응답(WebClientResponseException): 해당 (서버 측 일시적 문제일 가능성) + * - 429 응답: 해당 (레이트 리밋 — 잠시 후 재요청하면 성공할 가능성이 있음) + * - 그 외 4xx 응답: 해당하지 않음 (요청 자체가 잘못됐거나 대상이 없는 등 이 항목만의 문제) + */ +public final class ExternalFailureClassifier { + + private ExternalFailureClassifier() { + } + + public static boolean isTransientFailure(Throwable t) { + if (t == null) { + return false; + } + return (t instanceof WebClientRequestException) + || (t instanceof TransientDataAccessException) + || (t instanceof WebClientResponseException e && isRetryableStatus(e.getStatusCode())); + } + + private static boolean isRetryableStatus(HttpStatusCode status) { + return status.is5xxServerError() || status.value() == HttpStatus.TOO_MANY_REQUESTS.value(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/retry/LoggingSkipListener.java b/src/main/java/com/codeit/team5/mopl/content/batch/retry/LoggingSkipListener.java new file mode 100644 index 00000000..946b04d4 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/retry/LoggingSkipListener.java @@ -0,0 +1,27 @@ +package com.codeit.team5.mopl.content.batch.retry; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.SkipListener; + +/** + * skip(Exception.class)로 조용히 넘어가는 항목이 무엇이었고 왜 스킵됐는지 로그로 남긴다. + * 그대로 두면 어떤 데이터가 유실됐는지 운영 중에 확인할 방법이 없다. + */ +@Slf4j +public class LoggingSkipListener implements SkipListener { + + @Override + public void onSkipInRead(Throwable t) { + log.warn("[Batch] read 단계 스킵 - error={}", t.getMessage()); + } + + @Override + public void onSkipInProcess(Object item, Throwable t) { + log.warn("[Batch] process 단계 스킵 - item={}, error={}", item, t.getMessage()); + } + + @Override + public void onSkipInWrite(Object item, Throwable t) { + log.warn("[Batch] write 단계 스킵 - item={}, error={}", item, t.getMessage()); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/retry/SelectiveRetryPolicy.java b/src/main/java/com/codeit/team5/mopl/content/batch/retry/SelectiveRetryPolicy.java new file mode 100644 index 00000000..165f8e8b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/retry/SelectiveRetryPolicy.java @@ -0,0 +1,24 @@ +package com.codeit.team5.mopl.content.batch.retry; + +import org.springframework.retry.RetryContext; +import org.springframework.retry.policy.SimpleRetryPolicy; + +/** + * 재시도해서 성공할 가능성이 있는 예외만 재시도 대상으로 판단하는 정책. + * 판단 기준은 {@link ExternalFailureClassifier}를 따른다 (스킵 정책과 공유). + */ +public class SelectiveRetryPolicy extends SimpleRetryPolicy { + + public SelectiveRetryPolicy(int maxAttempts) { + super(maxAttempts); + } + + @Override + public boolean canRetry(RetryContext context) { + Throwable t = context.getLastThrowable(); + if (t == null) { + return true; + } + return ExternalFailureClassifier.isTransientFailure(t) && super.canRetry(context); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/retry/SelectiveSkipPolicy.java b/src/main/java/com/codeit/team5/mopl/content/batch/retry/SelectiveSkipPolicy.java new file mode 100644 index 00000000..3ad9cf2f --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/retry/SelectiveSkipPolicy.java @@ -0,0 +1,32 @@ +package com.codeit.team5.mopl.content.batch.retry; + +import org.springframework.batch.core.step.skip.SkipLimitExceededException; +import org.springframework.batch.core.step.skip.SkipPolicy; + +/** + * 재시도까지 실패한 예외가 시스템 전역 문제({@link ExternalFailureClassifier})로 보이면 스킵하지 않고 + * Step을 즉시 실패시키고, 그 외(대상 항목만의 문제, 예: 영구적인 404)는 skipLimit 내에서 스킵한다. + *

+ * noSkip(WebClientException.class)처럼 예외 타입만으로 뭉뚱그리면, 재시도 정책이 재시도 대상이 + * 아니라고 판단한 4xx까지도 스킵이 막혀 항목 하나 때문에 전체 배치가 중단된다. 이 정책은 재시도 + * 정책과 동일한 기준으로 판단해 그 불일치를 없앤다. + */ +public class SelectiveSkipPolicy implements SkipPolicy { + + private final int skipLimit; + + public SelectiveSkipPolicy(int skipLimit) { + this.skipLimit = skipLimit; + } + + @Override + public boolean shouldSkip(Throwable t, long skipCount) throws SkipLimitExceededException { + if (ExternalFailureClassifier.isTransientFailure(t)) { + return false; + } + if (skipCount >= skipLimit) { + throw new SkipLimitExceededException(skipLimit, t); + } + return true; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/scheduler/ContentCollectionScheduler.java b/src/main/java/com/codeit/team5/mopl/content/batch/scheduler/ContentCollectionScheduler.java new file mode 100644 index 00000000..c3af68ba --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/scheduler/ContentCollectionScheduler.java @@ -0,0 +1,75 @@ +package com.codeit.team5.mopl.content.batch.scheduler; + +import java.time.LocalDate; +import java.time.ZoneId; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ContentCollectionScheduler { + + private final JobLauncher asyncJobLauncher; + private final Job tmdbMovieJob; + private final Job tmdbTvSeriesJob; + private final Job sportsDbDayJob; + + @Value("${tmdb.batch.daily-end-page}") + private int dailyEndPage; + + public ContentCollectionScheduler( + @Qualifier("asyncJobLauncher") JobLauncher asyncJobLauncher, + @Qualifier("tmdbMovieJob") Job tmdbMovieJob, + @Qualifier("tmdbTvSeriesJob") Job tmdbTvSeriesJob, + @Qualifier("sportsDbDayJob") Job sportsDbDayJob + ) { + this.asyncJobLauncher = asyncJobLauncher; + this.tmdbMovieJob = tmdbMovieJob; + this.tmdbTvSeriesJob = tmdbTvSeriesJob; + this.sportsDbDayJob = sportsDbDayJob; + } + + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void runTmdbMovieJob() { + log.info("[Scheduler] TMDB 영화 수집 시작"); + run(tmdbMovieJob, new JobParametersBuilder() + .addString("startPage", "1") + .addString("endPage", String.valueOf(dailyEndPage)) + .addString("run.id", String.valueOf(System.currentTimeMillis())) + .toJobParameters()); + } + + @Scheduled(cron = "0 3 3 * * *", zone = "Asia/Seoul") + public void runTmdbTvSeriesJob() { + log.info("[Scheduler] TMDB TV 시리즈 수집 시작"); + run(tmdbTvSeriesJob, new JobParametersBuilder() + .addString("startPage", "1") + .addString("endPage", String.valueOf(dailyEndPage)) + .addString("run.id", String.valueOf(System.currentTimeMillis())) + .toJobParameters()); + } + + @Scheduled(cron = "0 6 3 * * *", zone = "Asia/Seoul") + public void runSportsDbDayJob() { + log.info("[Scheduler] SportsDB 일별 경기 수집 시작"); + run(sportsDbDayJob, new JobParametersBuilder() + .addString("date", LocalDate.now(ZoneId.of("Asia/Seoul")).toString()) + .addString("run.id", String.valueOf(System.currentTimeMillis())) + .toJobParameters()); + } + +private void run(Job job, JobParameters params) { + try { + asyncJobLauncher.run(job, params); + } catch (Exception e) { + log.error("[Scheduler] Job 실행 실패 - job={}, error={}", job.getName(), e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java new file mode 100644 index 00000000..49399a85 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriter.java @@ -0,0 +1,104 @@ +package com.codeit.team5.mopl.content.batch.writer; + +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentStats; +import com.codeit.team5.mopl.content.entity.ContentTag; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.repository.ContentStatsRepository; +import com.codeit.team5.mopl.tag.entity.Tag; +import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.util.StringUtils; + +@Slf4j +@RequiredArgsConstructor +public class ContentItemWriter implements ItemWriter { + + private final ContentRepository contentRepository; + private final ContentStatsRepository contentStatsRepository; + private final BinaryContentRepository binaryContentRepository; + private final TagRepository tagRepository; + + @Override + public void write(Chunk chunk) { + List items = new java.util.ArrayList<>(chunk.getItems()); + + // 1. Content 일괄 저장 (청크 내 externalId 중복 제거 — TMDB가 페이지 간 동일 영화를 중복 반환하는 경우 대비) + List deduplicatedItems = items.stream() + .collect(Collectors.toMap( + item -> item.content().getExternalId(), + Function.identity(), + (a, b) -> a, + LinkedHashMap::new + )) + .values() + .stream() + .toList(); + + List contents = deduplicatedItems.stream() + .map(ContentWithMetaData::content) + .toList(); + contentRepository.saveAll(contents); + + // 2. ContentStats 일괄 저장 + List stats = contents.stream() + .map(ContentStats::create) + .toList(); + contentStatsRepository.saveAll(stats); + + // 3. 썸네일 저장 (아이템별로 별도 BinaryContent를 저장 — thumbnail_id는 1:1 유니크 제약이라 + // 서로 다른 콘텐츠가 같은 thumbnailUrl을 갖더라도 BinaryContent를 공유할 수 없다. + // saveAll은 입력 순서를 보존해 반환하므로 인덱스로 1:1 매칭한다. + List itemsWithThumbnail = deduplicatedItems.stream() + .filter(item -> StringUtils.hasText(item.thumbnailUrl())) + .toList(); + if (!itemsWithThumbnail.isEmpty()) { + List savedThumbnails = binaryContentRepository.saveAll( + itemsWithThumbnail.stream() + .map(item -> BinaryContent.externalUrl(item.thumbnailUrl())) + .toList() + ); + for (int i = 0; i < itemsWithThumbnail.size(); i++) { + itemsWithThumbnail.get(i).content().attachThumbnail(savedThumbnails.get(i)); + } + } + + // 4. 태그 저장 + List allTagNames = deduplicatedItems.stream() + .flatMap(item -> item.tagNames().stream()) + .distinct() + .toList(); + + if (!allTagNames.isEmpty()) { + Map existingTags = tagRepository.findByNameIn(allTagNames).stream() + .collect(Collectors.toMap(Tag::getName, Function.identity())); + + List newTags = allTagNames.stream() + .filter(name -> !existingTags.containsKey(name)) + .map(Tag::create) + .toList(); + + if (!newTags.isEmpty()) { + tagRepository.saveAll(newTags).forEach(tag -> existingTags.put(tag.getName(), tag)); + } + + deduplicatedItems.forEach(item -> item.tagNames().forEach(tagName -> + item.content().addTag(ContentTag.create(item.content(), existingTags.get(tagName))) + )); + } + + log.info("[Batch] {}건 저장 완료 (청크 원본: {}건)", deduplicatedItems.size(), items.size()); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/client/sportsdb/SportsDbApiClient.java b/src/main/java/com/codeit/team5/mopl/content/client/sportsdb/SportsDbApiClient.java index f1fb5fac..99c619ef 100644 --- a/src/main/java/com/codeit/team5/mopl/content/client/sportsdb/SportsDbApiClient.java +++ b/src/main/java/com/codeit/team5/mopl/content/client/sportsdb/SportsDbApiClient.java @@ -25,4 +25,16 @@ public SportsDbEventListResponse fetchEventsBySeason(String leagueId, String sea .bodyToMono(SportsDbEventListResponse.class) .block(); } + + public SportsDbEventListResponse fetchEventsByDay(String leagueId, String date) { + return sportsDbWebClient.get() + .uri(uriBuilder -> uriBuilder + .path("/eventsday.php") + .queryParam("d", date) + .queryParam("l", leagueId) + .build()) + .retrieve() + .bodyToMono(SportsDbEventListResponse.class) + .block(); + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java b/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java index 20892ad2..39cd681b 100644 --- a/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java +++ b/src/main/java/com/codeit/team5/mopl/content/controller/ContentCollectionController.java @@ -1,54 +1,109 @@ package com.codeit.team5.mopl.content.controller; +import com.codeit.team5.mopl.content.batch.exception.BatchJobLaunchException; import com.codeit.team5.mopl.content.controller.api.ContentCollectionApi; import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbLeague; -import com.codeit.team5.mopl.content.service.SportsDbContentService; -import com.codeit.team5.mopl.content.service.TmdbContentService; -import lombok.RequiredArgsConstructor; +import com.codeit.team5.mopl.content.dto.request.PageRangeRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -// TODO 배포 시 제거 예정 - 초기 데이터 수집용 임시 엔드포인트 @Slf4j +@Validated @RestController @RequestMapping("/api/admin/contents/collect") -@RequiredArgsConstructor public class ContentCollectionController implements ContentCollectionApi { - private final TmdbContentService tmdbContentService; - private final SportsDbContentService sportsDbContentService; + private final JobLauncher asyncJobLauncher; + private final Job tmdbMovieJob; + private final Job tmdbTvSeriesJob; + private final Job sportsDbEventJob; + private final Job sportsDbDayJob; - @PostMapping("/tmdb/movies") - public ResponseEntity collectTmdbMovies( - @RequestParam(defaultValue = "1") int startPage, - @RequestParam(defaultValue = "1") int endPage + public ContentCollectionController( + @Qualifier("asyncJobLauncher") JobLauncher asyncJobLauncher, + @Qualifier("tmdbMovieJob") Job tmdbMovieJob, + @Qualifier("tmdbTvSeriesJob") Job tmdbTvSeriesJob, + @Qualifier("sportsDbEventJob") Job sportsDbEventJob, + @Qualifier("sportsDbDayJob") Job sportsDbDayJob ) { - log.info("TMDB 영화 수집 요청: {}~{}페이지", startPage, endPage); - tmdbContentService.collectMovies(startPage, endPage); + this.asyncJobLauncher = asyncJobLauncher; + this.tmdbMovieJob = tmdbMovieJob; + this.tmdbTvSeriesJob = tmdbTvSeriesJob; + this.sportsDbEventJob = sportsDbEventJob; + this.sportsDbDayJob = sportsDbDayJob; + } + + @PostMapping("/tmdb/movies") + public ResponseEntity collectTmdbMovies(@Valid @ModelAttribute PageRangeRequest request) { + log.info("TMDB 영화 수집 요청: {}~{}페이지", request.startPage(), request.endPage()); + run(tmdbMovieJob, new JobParametersBuilder() + .addString("startPage", String.valueOf(request.startPage())) + .addString("endPage", String.valueOf(request.endPage())) + .addString("run.id", String.valueOf(System.currentTimeMillis())) + .toJobParameters()); return ResponseEntity.accepted().build(); } @PostMapping("/tmdb/tv") - public ResponseEntity collectTmdbTvSeries( - @RequestParam(defaultValue = "1") int startPage, - @RequestParam(defaultValue = "1") int endPage - ) { - log.info("TMDB TV 시리즈 수집 요청: {}~{}페이지", startPage, endPage); - tmdbContentService.collectTvSeries(startPage, endPage); + public ResponseEntity collectTmdbTvSeries(@Valid @ModelAttribute PageRangeRequest request) { + log.info("TMDB TV 시리즈 수집 요청: {}~{}페이지", request.startPage(), request.endPage()); + run(tmdbTvSeriesJob, new JobParametersBuilder() + .addString("startPage", String.valueOf(request.startPage())) + .addString("endPage", String.valueOf(request.endPage())) + .addString("run.id", String.valueOf(System.currentTimeMillis())) + .toJobParameters()); return ResponseEntity.accepted().build(); } @PostMapping("/sports") public ResponseEntity collectSportsEvents( @RequestParam SportsDbLeague league, + @Pattern(regexp = "^\\d{4}-\\d{4}$", message = "시즌 형식은 YYYY-YYYY이어야 합니다. (예: 2023-2024)") @RequestParam String season ) { log.info("SportsDB 경기 수집 요청: league={}, season={}", league.getName(), season); - sportsDbContentService.collectEvents(league.getLeagueId(), season); + run(sportsDbEventJob, new JobParametersBuilder() + .addString("leagueId", league.getLeagueId()) + .addString("season", season) + .addString("run.id", String.valueOf(System.currentTimeMillis())) + .toJobParameters()); return ResponseEntity.accepted().build(); } + + @PostMapping("/sports/day") + public ResponseEntity collectSportsEventsByDay( + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @RequestParam LocalDate date + ) { + log.info("SportsDB 일별 경기 수집 요청: date={}", date); + run(sportsDbDayJob, new JobParametersBuilder() + .addString("date", date.toString()) + .addString("run.id", String.valueOf(System.currentTimeMillis())) + .toJobParameters()); + return ResponseEntity.accepted().build(); + } + + private void run(Job job, JobParameters params) { + try { + asyncJobLauncher.run(job, params); + } catch (Exception e) { + log.error("[Controller] Job 실행 실패 - job={}, error={}", job.getName(), e.getMessage(), e); + throw new BatchJobLaunchException(job.getName(), e); + } + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentCollectionApi.java b/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentCollectionApi.java index f36b08e1..f1d3606a 100644 --- a/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentCollectionApi.java +++ b/src/main/java/com/codeit/team5/mopl/content/controller/api/ContentCollectionApi.java @@ -1,6 +1,7 @@ package com.codeit.team5.mopl.content.controller.api; import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbLeague; +import com.codeit.team5.mopl.content.dto.request.PageRangeRequest; import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -9,7 +10,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "[어드민] 콘텐츠 수집", description = "외부 API를 통한 콘텐츠 데이터 수집 API") public interface ContentCollectionApi { @@ -17,6 +24,8 @@ public interface ContentCollectionApi { @Operation(summary = "TMDB 영화 수집", description = "TMDB API에서 인기순으로 영화 데이터를 수집합니다.") @ApiResponses({ @ApiResponse(responseCode = "202", description = "수집 요청 수락 (백그라운드 처리)"), + @ApiResponse(responseCode = "400", description = "잘못된 페이지 범위", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "401", description = "인증 오류", content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "403", description = "권한 오류", @@ -25,13 +34,14 @@ public interface ContentCollectionApi { content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) }) ResponseEntity collectTmdbMovies( - @Parameter(description = "시작 페이지", example = "1") int startPage, - @Parameter(description = "종료 페이지", example = "5") int endPage + @Valid @ModelAttribute PageRangeRequest request ); @Operation(summary = "TMDB TV 시리즈 수집", description = "TMDB API에서 인기순으로 TV 시리즈 데이터를 수집합니다.") @ApiResponses({ @ApiResponse(responseCode = "202", description = "수집 요청 수락 (백그라운드 처리)"), + @ApiResponse(responseCode = "400", description = "잘못된 페이지 범위", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "401", description = "인증 오류", content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "403", description = "권한 오류", @@ -40,13 +50,14 @@ ResponseEntity collectTmdbMovies( content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) }) ResponseEntity collectTmdbTvSeries( - @Parameter(description = "시작 페이지", example = "1") int startPage, - @Parameter(description = "종료 페이지", example = "5") int endPage + @Valid @ModelAttribute PageRangeRequest request ); @Operation(summary = "SportsDB 경기 수집", description = "SportsDB API에서 리그와 시즌을 지정해 경기 데이터를 수집합니다.") @ApiResponses({ @ApiResponse(responseCode = "202", description = "수집 요청 수락 (백그라운드 처리)"), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "401", description = "인증 오류", content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), @ApiResponse(responseCode = "403", description = "권한 오류", @@ -55,7 +66,28 @@ ResponseEntity collectTmdbTvSeries( content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) }) ResponseEntity collectSportsEvents( - @Parameter(description = "리그", schema = @Schema(implementation = SportsDbLeague.class)) SportsDbLeague league, - @Parameter(description = "시즌 (예: 2023-2024)", example = "2023-2024") String season + @Parameter(description = "리그", schema = @Schema(implementation = SportsDbLeague.class)) + @RequestParam SportsDbLeague league, + @Parameter(description = "시즌 (예: 2023-2024)", example = "2023-2024") + @Pattern(regexp = "^\\d{4}-\\d{4}$", message = "시즌 형식은 YYYY-YYYY이어야 합니다. (예: 2023-2024)") + @RequestParam String season + ); + + @Operation(summary = "SportsDB 일별 경기 수집", description = "SportsDB API에서 특정 날짜의 전체 리그 경기 데이터를 수집합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "수집 요청 수락 (백그라운드 처리)"), + @ApiResponse(responseCode = "400", description = "잘못된 날짜 형식", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "403", description = "권한 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + ResponseEntity collectSportsEventsByDay( + @Parameter(description = "날짜 (예: 2024-12-26)", example = "2024-12-26") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @RequestParam LocalDate date ); } diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/external/sportsdb/SportsDbLeague.java b/src/main/java/com/codeit/team5/mopl/content/dto/external/sportsdb/SportsDbLeague.java index 31bc2ebf..7e49780a 100644 --- a/src/main/java/com/codeit/team5/mopl/content/dto/external/sportsdb/SportsDbLeague.java +++ b/src/main/java/com/codeit/team5/mopl/content/dto/external/sportsdb/SportsDbLeague.java @@ -1,5 +1,8 @@ package com.codeit.team5.mopl.content.dto.external.sportsdb; +import lombok.Getter; + +@Getter public enum SportsDbLeague { EPL("4328", "English Premier League"), EFL_CHAMPIONSHIP("4329", "English League Championship"), @@ -19,12 +22,4 @@ public enum SportsDbLeague { this.leagueId = leagueId; this.name = name; } - - public String getLeagueId() { - return leagueId; - } - - public String getName() { - return name; - } } diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbMovieDto.java b/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbMovieDto.java index be4432c2..97a4efe7 100644 --- a/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbMovieDto.java +++ b/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbMovieDto.java @@ -14,4 +14,7 @@ public record TmdbMovieDto( @JsonProperty("vote_average") double voteAverage, @JsonProperty("original_language") String originalLanguage ) { + public TmdbMovieDto { + genreIds = genreIds == null ? List.of() : genreIds; + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbTvDto.java b/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbTvDto.java index 2dd9c92d..afffb144 100644 --- a/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbTvDto.java +++ b/src/main/java/com/codeit/team5/mopl/content/dto/external/tmdb/TmdbTvDto.java @@ -14,4 +14,7 @@ public record TmdbTvDto( @JsonProperty("vote_average") double voteAverage, @JsonProperty("original_language") String originalLanguage ) { + public TmdbTvDto { + genreIds = genreIds == null ? List.of() : genreIds; + } } diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/PageRangeRequest.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/PageRangeRequest.java new file mode 100644 index 00000000..524e5758 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/PageRangeRequest.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.content.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@ValidPageRange +public record PageRangeRequest( + @NotNull @Min(1) Integer startPage, + @NotNull @Min(1) Integer endPage +) {} diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidPageRange.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidPageRange.java new file mode 100644 index 00000000..b35e59d9 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidPageRange.java @@ -0,0 +1,20 @@ +package com.codeit.team5.mopl.content.dto.request; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidPageRangeValidator.class) +public @interface ValidPageRange { + + String message() default "endPage는 startPage 이상이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidPageRangeValidator.java b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidPageRangeValidator.java new file mode 100644 index 00000000..564a419a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/dto/request/ValidPageRangeValidator.java @@ -0,0 +1,25 @@ +package com.codeit.team5.mopl.content.dto.request; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidPageRangeValidator implements ConstraintValidator { + + @Override + public boolean isValid(PageRangeRequest request, ConstraintValidatorContext context) { + // null/누락, startPage >= 1 여부는 @NotNull/@Min(1)이 이미 검사하므로 여기서는 + // endPage < startPage 같은 교차 필드 조건만 검사한다. + if (request == null || request.startPage() == null || request.endPage() == null) { + return true; + } + if (request.endPage() >= request.startPage()) { + return true; + } + + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()) + .addPropertyNode("endPage") + .addConstraintViolation(); + return false; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/content/service/SportsDbContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/SportsDbContentService.java deleted file mode 100644 index dd1cd334..00000000 --- a/src/main/java/com/codeit/team5/mopl/content/service/SportsDbContentService.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.codeit.team5.mopl.content.service; - -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; -import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils; -import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient; -import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; -import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventListResponse; -import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.content.entity.ContentSource; -import com.codeit.team5.mopl.content.entity.ContentStats; -import com.codeit.team5.mopl.content.entity.ContentTag; -import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; -import com.codeit.team5.mopl.content.repository.ContentStatsRepository; -import com.codeit.team5.mopl.tag.entity.Tag; -import com.codeit.team5.mopl.tag.repository.TagRepository; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionTemplate; -import org.springframework.util.StringUtils; - -/** - * TheSportsDB API에서 스포츠 경기 이벤트를 수집하여 DB에 저장하는 서비스. - * - *

수집 기준: - *

- *

- */ -@Slf4j -@Service -@RequiredArgsConstructor -public class SportsDbContentService { - - private final SportsDbApiClient sportsDbApiClient; - private final ContentRepository contentRepository; - private final ContentStatsRepository contentStatsRepository; - private final BinaryContentRepository binaryContentRepository; - private final TagRepository tagRepository; - private final TransactionTemplate transactionTemplate; - private final ObjectMapper objectMapper; - - /** - * 특정 리그의 한 시즌 전체 경기를 수집한다. - * - * @param leagueId TheSportsDB 리그 ID (예: "4328" = EPL) - * @param season 시즌 문자열 (예: "2023-2024") - */ - @Async("contentCollectionExecutor") - public void collectEvents(String leagueId, String season) { - SportsDbEventListResponse response = sportsDbApiClient.fetchEventsBySeason(leagueId, season); - - if (response == null || response.events() == null || response.events().isEmpty()) { - log.warn("[SportsDB] 수집된 경기 없음 - leagueId={}, season={}", leagueId, season); - return; - } - - transactionTemplate.execute(status -> { - Tag leagueTag = resolveLeagueTag(response.events().get(0).strLeague()); - response.events().forEach(dto -> saveEventIfAbsent(dto, leagueTag)); - return null; - }); - log.info("[SportsDB] 경기 수집 완료 - leagueId={}, season={}, {}건", leagueId, season, response.events().size()); - } - - private void saveEventIfAbsent(SportsDbEventDto dto, Tag leagueTag) { - if (contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, dto.idEvent())) { - log.debug("[SportsDB] 경기 스킵 (이미 존재) - idEvent={}, event={}", dto.idEvent(), dto.strEvent()); - return; - } - - Content content = contentRepository.save( - Content.createByExternalSource( - ContentType.SPORT, - dto.strEvent(), - buildDescription(dto), - ContentSource.SPORTS_DB, - dto.idEvent(), - parseDate(dto.dateEvent()), - buildMetadata(dto) - ) - ); - - attachThumbnail(content, dto.strThumb()); - if (leagueTag != null) { - content.addTag(ContentTag.create(content, leagueTag)); - } - contentStatsRepository.save(ContentStats.create(content)); - } - - private Tag resolveLeagueTag(String leagueName) { - if (!StringUtils.hasText(leagueName)) { - return null; - } - String normalized = leagueName.trim().toLowerCase(); - return tagRepository.findByName(normalized) - .orElseGet(() -> tagRepository.save(Tag.create(normalized))); - } - - private void attachThumbnail(Content content, String thumbUrl) { - ContentCollectionUtils.attachThumbnail(content, thumbUrl, binaryContentRepository, ""); - } - - private String buildDescription(SportsDbEventDto dto) { - String score = hasScore(dto) - ? dto.intHomeScore() + " - " + dto.intAwayScore() - : "vs"; - return String.format("%s | %s %s %s", dto.strLeague(), dto.strHomeTeam(), score, dto.strAwayTeam()); - } - - private boolean hasScore(SportsDbEventDto dto) { - return StringUtils.hasText(dto.intHomeScore()) && StringUtils.hasText(dto.intAwayScore()); - } - - private String buildMetadata(SportsDbEventDto dto) { - try { - Map metadata = new HashMap<>(); - metadata.put("sport", dto.strSport()); - metadata.put("league", dto.strLeague()); - return objectMapper.writeValueAsString(metadata); - } catch (JsonProcessingException e) { - log.warn("[SportsDB] metadata 직렬화 실패 - idEvent={}", dto.idEvent()); - return "{}"; - } - } - - private Instant parseDate(String date) { - return ContentCollectionUtils.parseDate(date, "SportsDB"); - } -} diff --git a/src/main/java/com/codeit/team5/mopl/content/service/TmdbContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/TmdbContentService.java deleted file mode 100644 index 314f33d9..00000000 --- a/src/main/java/com/codeit/team5/mopl/content/service/TmdbContentService.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.codeit.team5.mopl.content.service; - -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; -import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils; -import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieGenre; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieListResponse; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvGenre; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvListResponse; -import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.content.entity.ContentSource; -import com.codeit.team5.mopl.content.entity.ContentStats; -import com.codeit.team5.mopl.content.entity.ContentTag; -import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; -import com.codeit.team5.mopl.content.repository.ContentStatsRepository; -import com.codeit.team5.mopl.tag.entity.Tag; -import com.codeit.team5.mopl.tag.repository.TagRepository; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Instant; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionTemplate; -import org.springframework.util.StringUtils; - -/** - * TMDB API에서 영화·TV 시리즈 콘텐츠를 수집하여 DB에 저장하는 서비스. - * - *

수집 기준: - *

- *

- */ -@Slf4j -@Service -@RequiredArgsConstructor -public class TmdbContentService { - - private static final String TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; - private static final long REQUEST_DELAY_MS = 300; - private static final int MAX_PAGE = 500; - - private final TmdbApiClient tmdbApiClient; - private final ContentRepository contentRepository; - private final ContentStatsRepository contentStatsRepository; - private final BinaryContentRepository binaryContentRepository; - private final TagRepository tagRepository; - private final TransactionTemplate transactionTemplate; - private final ObjectMapper objectMapper; - - private record TmdbPageResult(List items, int totalPages) {} - - private record TmdbContentData( - ContentType type, - String title, - String externalId, - String overview, - String posterPath, - List tagNames, - String releaseDate, - double voteAverage, - String originalLanguage - ) {} - - /** - * TMDB 인기 영화를 페이지 범위 단위로 수집한다. - * - * @param startPage 수집 시작 페이지 (1-based) - * @param endPage 수집 종료 페이지 (최대 {@value #MAX_PAGE}로 자동 클램핑) - */ - @Async("contentCollectionExecutor") - public void collectMovies(int startPage, int endPage) { - collectByPage("영화", startPage, endPage, page -> { - TmdbMovieListResponse response = tmdbApiClient.fetchMovies(page); - List items = response.results().stream() - .filter(dto -> StringUtils.hasText(dto.title())) - .map(this::toContentData) - .toList(); - return new TmdbPageResult(items, response.totalPages()); - }); - } - - /** - * TMDB 인기 TV 시리즈를 페이지 범위 단위로 수집한다. - * - * @param startPage 수집 시작 페이지 (1-based) - * @param endPage 수집 종료 페이지 (최대 {@value #MAX_PAGE}로 자동 클램핑) - */ - @Async("contentCollectionExecutor") - public void collectTvSeries(int startPage, int endPage) { - collectByPage("TV 시리즈", startPage, endPage, page -> { - TmdbTvListResponse response = tmdbApiClient.fetchTvSeries(page); - List items = response.results().stream() - .filter(dto -> StringUtils.hasText(dto.name())) - .map(this::toContentData) - .toList(); - return new TmdbPageResult(items, response.totalPages()); - }); - } - - private void collectByPage(String label, int startPage, int endPage, - Function fetcher) { - int clampedEnd = Math.min(endPage, MAX_PAGE); - if (clampedEnd < endPage) { - log.warn("[TMDB] endPage {}가 최대 허용 페이지({})를 초과하여 {}로 조정됩니다.", endPage, MAX_PAGE, clampedEnd); - } - for (int page = startPage; page <= clampedEnd; page++) { - TmdbPageResult result = fetcher.apply(page); - int actualLastPage = Math.min(result.totalPages(), clampedEnd); - transactionTemplate.execute(status -> { - result.items().forEach(this::saveIfAbsent); - return null; - }); - log.info("[TMDB] {} 수집 완료 - {}/{}페이지 ({}건)", label, page, result.totalPages(), result.items().size()); - if (page >= actualLastPage) { - log.info("[TMDB] {} 수집 종료 - 실제 마지막 페이지({}) 도달", label, actualLastPage); - break; - } - if (sleep()) { - log.info("[TMDB] {} 수집 중단 - 인터럽트 감지", label); - break; - } - } - } - - private void saveIfAbsent(TmdbContentData data) { - if (contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, data.externalId())) { - log.debug("[TMDB] 스킵 (이미 존재) - externalId={}, title={}", data.externalId(), data.title()); - return; - } - - Content content = contentRepository.save( - Content.createByExternalSource( - data.type(), - data.title(), - data.overview(), - ContentSource.TMDB, - data.externalId(), - parseDate(data.releaseDate()), - buildMetadata(data.voteAverage(), data.originalLanguage()) - ) - ); - - attachThumbnail(content, data.posterPath()); - attachTags(content, data.tagNames()); - contentStatsRepository.save(ContentStats.create(content)); - } - - private TmdbContentData toContentData(TmdbMovieDto dto) { - List tagNames = dto.genreIds().stream() - .map(id -> TmdbMovieGenre.fromId(id).map(TmdbMovieGenre::getLabel)) - .filter(java.util.Optional::isPresent) - .map(java.util.Optional::get) - .toList(); - return new TmdbContentData(ContentType.MOVIE, dto.title(), String.valueOf(dto.id()), - dto.overview(), dto.posterPath(), tagNames, dto.releaseDate(), - dto.voteAverage(), dto.originalLanguage()); - } - - private TmdbContentData toContentData(TmdbTvDto dto) { - List tagNames = dto.genreIds().stream() - .map(id -> TmdbTvGenre.fromId(id).map(TmdbTvGenre::getLabel)) - .filter(java.util.Optional::isPresent) - .map(java.util.Optional::get) - .toList(); - return new TmdbContentData(ContentType.TV_SERIES, dto.name(), String.valueOf(dto.id()), - dto.overview(), dto.posterPath(), tagNames, dto.firstAirDate(), - dto.voteAverage(), dto.originalLanguage()); - } - - private void attachThumbnail(Content content, String posterPath) { - ContentCollectionUtils.attachThumbnail(content, posterPath, binaryContentRepository, TMDB_IMAGE_BASE_URL); - } - - private void attachTags(Content content, List tagNames) { - if (tagNames.isEmpty()) { - return; - } - // 소문자 키로 중복 제거하되, 저장 값은 원본 라벨 유지 (e.g. "SF" → key:"sf", label:"SF") - Map keyToLabel = new LinkedHashMap<>(); - tagNames.stream() - .map(String::trim) - .filter(s -> !s.isEmpty()) - .forEach(label -> keyToLabel.putIfAbsent(label.toLowerCase(), label)); - - Map existingTags = tagRepository.findByNameIn(List.copyOf(keyToLabel.values())).stream() - .collect(Collectors.toMap(tag -> tag.getName().toLowerCase(), Function.identity())); - - List newTags = keyToLabel.entrySet().stream() - .filter(e -> !existingTags.containsKey(e.getKey())) - .map(e -> Tag.create(e.getValue())) - .toList(); - if (!newTags.isEmpty()) { - tagRepository.saveAll(newTags).forEach(tag -> existingTags.put(tag.getName().toLowerCase(), tag)); - } - - keyToLabel.keySet().forEach(key -> content.addTag(ContentTag.create(content, existingTags.get(key)))); - } - - private Instant parseDate(String date) { - return ContentCollectionUtils.parseDate(date, "TMDB"); - } - - private String buildMetadata(double voteAverage, String originalLanguage) { - try { - Map metadata = new HashMap<>(); - metadata.put("voteAverage", voteAverage); - metadata.put("originalLanguage", originalLanguage); - return objectMapper.writeValueAsString(metadata); - } catch (JsonProcessingException e) { - log.warn("[TMDB] metadata 직렬화 실패 - voteAverage={}, originalLanguage={}", voteAverage, originalLanguage); - return "{}"; - } - } - - private boolean sleep() { - try { - Thread.sleep(REQUEST_DELAY_MS); - return false; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return true; - } - } -} diff --git a/src/main/java/com/codeit/team5/mopl/global/dto/suggestion/ErrorResponseSuggestion.java b/src/main/java/com/codeit/team5/mopl/global/dto/suggestion/ErrorResponseSuggestion.java index 5da7dcdd..2e630f56 100644 --- a/src/main/java/com/codeit/team5/mopl/global/dto/suggestion/ErrorResponseSuggestion.java +++ b/src/main/java/com/codeit/team5/mopl/global/dto/suggestion/ErrorResponseSuggestion.java @@ -13,6 +13,7 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; public record ErrorResponseSuggestion(String exceptionType, String message, Object details) { @@ -91,4 +92,12 @@ public static ErrorResponseSuggestion from(MissingServletRequestParameterExcepti ); return new ErrorResponseSuggestion("INVALID_INPUT", "잘못된 입력값입니다.", details); } + + public static ErrorResponseSuggestion from(MethodArgumentTypeMismatchException ex) { + Map> details = Map.of( + ex.getName(), + List.of("허용되지 않는 값입니다: " + ex.getValue()) + ); + return new ErrorResponseSuggestion("INVALID_INPUT", "잘못된 입력값입니다.", details); + } } diff --git a/src/main/java/com/codeit/team5/mopl/global/exception/GlobalExceptionHandler.java b/src/main/java/com/codeit/team5/mopl/global/exception/GlobalExceptionHandler.java index d10e7eea..0190eb64 100644 --- a/src/main/java/com/codeit/team5/mopl/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/codeit/team5/mopl/global/exception/GlobalExceptionHandler.java @@ -16,11 +16,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.core.task.TaskRejectedException; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @Slf4j @RestControllerAdvice @@ -88,6 +90,17 @@ public void handleIOException(IOException e) { log.debug("클라이언트 연결 종료로 응답 전송 실패: {}", e.getMessage()); } + // 배치 작업 스레드 풀의 큐까지 가득 찬 상태에서 새 수집 요청이 들어오면 AbortPolicy가 + // TaskRejectedException을 던진다. 일시적인 과부하이므로 503으로 매핑해 재시도를 유도한다. + @ExceptionHandler(TaskRejectedException.class) + public ResponseEntity handleTaskRejectedException(TaskRejectedException e) { + log.warn("배치 작업 큐 포화로 요청 거부: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponseSuggestion("SERVICE_UNAVAILABLE", + "현재 수집 작업이 많아 요청을 처리할 수 없습니다. 잠시 후 다시 시도해주세요.", null)); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error("Unexpected exception", e); @@ -120,4 +133,14 @@ public ResponseEntity handleMissingServletRequestParame .body(ErrorResponseSuggestion.from(e)); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e + ) { + log.warn(e.toString()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponseSuggestion.from(e)); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index eb07ff0b..59503db7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,9 @@ spring: name: mopl profiles: active: ${SPRING_PROFILES_ACTIVE:dev} + batch: + job: + enabled: false jpa: open-in-view: false hibernate: @@ -51,6 +54,8 @@ mopl: tmdb: access-token: ${TMDB_ACCESS_TOKEN} base-url: https://api.themoviedb.org/3 + batch: + daily-end-page: 5 sports-db: api-key: ${SPORTS_DB_API_KEY} diff --git a/src/main/resources/db/migration/V16__add_spring_batch_metadata.sql b/src/main/resources/db/migration/V16__add_spring_batch_metadata.sql new file mode 100644 index 00000000..96298bd3 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_spring_batch_metadata.sql @@ -0,0 +1,74 @@ +-- Spring Batch 메타데이터 테이블 +-- Job 실행 이력, Step 실행 이력, 파라미터 등을 추적하는 테이블들 + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_SEQ MAXVALUE 9223372036854775807 NO CYCLE; + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + CONSTRAINT JOB_INST_UN UNIQUE (JOB_NAME, JOB_KEY) +); + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + CONSTRAINT JOB_INST_EXEC_FK FOREIGN KEY (JOB_INSTANCE_ID) REFERENCES BATCH_JOB_INSTANCE (JOB_INSTANCE_ID) +); + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + CONSTRAINT JOB_EXEC_PARAMS_FK FOREIGN KEY (JOB_EXECUTION_ID) REFERENCES BATCH_JOB_EXECUTION (JOB_EXECUTION_ID) +); + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + CONSTRAINT JOB_EXEC_STEP_FK FOREIGN KEY (JOB_EXECUTION_ID) REFERENCES BATCH_JOB_EXECUTION (JOB_EXECUTION_ID) +); + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + CONSTRAINT STEP_EXEC_CTX_FK FOREIGN KEY (STEP_EXECUTION_ID) REFERENCES BATCH_STEP_EXECUTION (STEP_EXECUTION_ID) +); + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + CONSTRAINT JOB_EXEC_CTX_FK FOREIGN KEY (JOB_EXECUTION_ID) REFERENCES BATCH_JOB_EXECUTION (JOB_EXECUTION_ID) +); diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessorTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessorTest.java new file mode 100644 index 00000000..fa622e9a --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/processor/SportsDbEventItemProcessorTest.java @@ -0,0 +1,101 @@ +package com.codeit.team5.mopl.content.batch.processor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SportsDbEventItemProcessorTest { + + @Mock + private ContentRepository contentRepository; + + private SportsDbEventItemProcessor processor; + + @BeforeEach + void setUp() { + processor = new SportsDbEventItemProcessor(contentRepository, new ObjectMapper()); + } + + @Test + @DisplayName("이미 존재하는 경기면 null을 반환한다") + void process_duplicate_returnsNull() throws Exception { + // given + SportsDbEventDto dto = new SportsDbEventDto("123", "Arsenal vs Chelsea", + "https://thumb.jpg", "2024-12-26", "English Premier League", + "Soccer", "Arsenal", "Chelsea", "2", "1"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "123")).willReturn(true); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("새로운 경기면 ContentWithMetaData를 반환한다") + void process_newEvent_returnsContentWithMetaData() throws Exception { + // given + SportsDbEventDto dto = new SportsDbEventDto("123", "Arsenal vs Chelsea", + "https://thumb.jpg", "2024-12-26", "English Premier League", + "Soccer", "Arsenal", "Chelsea", "2", "1"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "123")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().getTitle()).isEqualTo("Arsenal vs Chelsea"); + assertThat(result.content().getType()).isEqualTo(ContentType.SPORT); + assertThat(result.content().getSource()).isEqualTo(ContentSource.SPORTS_DB); + assertThat(result.thumbnailUrl()).isEqualTo("https://thumb.jpg"); + } + + @Test + @DisplayName("리그명이 소문자로 정규화되어 tagNames에 포함된다") + void process_leagueName_normalizedToLowerCase() throws Exception { + // given + SportsDbEventDto dto = new SportsDbEventDto("456", "Arsenal vs Chelsea", + null, "2024-12-26", "English Premier League", + "Soccer", "Arsenal", "Chelsea", null, null); + given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "456")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.tagNames()).containsExactly("english premier league"); + } + + @Test + @DisplayName("스코어가 없으면 description에 vs가 포함된다") + void process_noScore_descriptionContainsVs() throws Exception { + // given + SportsDbEventDto dto = new SportsDbEventDto("789", "Arsenal vs Chelsea", + null, "2024-12-26", "English Premier League", + "Soccer", "Arsenal", "Chelsea", null, null); + given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "789")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().getDescription()).contains("vs"); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessorTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessorTest.java new file mode 100644 index 00000000..3a40769b --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbMovieItemProcessorTest.java @@ -0,0 +1,114 @@ +package com.codeit.team5.mopl.content.batch.processor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TmdbMovieItemProcessorTest { + + @Mock + private ContentRepository contentRepository; + + private TmdbMovieItemProcessor processor; + + @BeforeEach + void setUp() { + processor = new TmdbMovieItemProcessor(contentRepository, new ObjectMapper()); + } + + @Test + @DisplayName("이미 존재하는 영화면 null을 반환한다") + void process_duplicate_returnsNull() throws Exception { + // given + TmdbMovieDto dto = new TmdbMovieDto(1L, "인터스텔라", "Interstellar", "overview", + "/poster.jpg", List.of(18L), "2014-11-05", 8.6, "en"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(true); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("새로운 영화면 ContentWithMetaData를 반환한다") + void process_newMovie_returnsContentWithMetaData() throws Exception { + // given + TmdbMovieDto dto = new TmdbMovieDto(1L, "인터스텔라", "Interstellar", "overview", + "/poster.jpg", List.of(18L), "2014-11-05", 8.6, "en"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().getTitle()).isEqualTo("인터스텔라"); + assertThat(result.content().getType()).isEqualTo(ContentType.MOVIE); + assertThat(result.content().getSource()).isEqualTo(ContentSource.TMDB); + assertThat(result.thumbnailUrl()).isEqualTo("https://image.tmdb.org/t/p/w500/poster.jpg"); + } + + @Test + @DisplayName("posterPath가 null이면 thumbnailUrl도 null이다") + void process_nullPosterPath_thumbnailUrlIsNull() throws Exception { + // given + TmdbMovieDto dto = new TmdbMovieDto(2L, "포스터없는영화", "No Poster", "overview", + null, List.of(), "2020-01-01", 7.0, "ko"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "2")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.thumbnailUrl()).isNull(); + } + + @Test + @DisplayName("genreId가 매핑되면 tagNames에 포함된다") + void process_validGenreId_tagNamesIncluded() throws Exception { + // given + TmdbMovieDto dto = new TmdbMovieDto(3L, "드라마영화", "Drama", "overview", + null, List.of(18L), "2020-01-01", 7.0, "ko"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "3")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.tagNames()).containsExactly("드라마"); + } + + @Test + @DisplayName("genreIds가 null이어도 예외 없이 tagNames가 빈 리스트로 처리된다") + void process_nullGenreIds_tagNamesEmpty() throws Exception { + // given + TmdbMovieDto dto = new TmdbMovieDto(4L, "장르없는영화", "NoGenre", "overview", + null, null, "2020-01-01", 7.0, "ko"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "4")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.tagNames()).isEmpty(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessorTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessorTest.java new file mode 100644 index 00000000..11570392 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/processor/TmdbTvSeriesItemProcessorTest.java @@ -0,0 +1,98 @@ +package com.codeit.team5.mopl.content.batch.processor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TmdbTvSeriesItemProcessorTest { + + @Mock + private ContentRepository contentRepository; + + private TmdbTvSeriesItemProcessor processor; + + @BeforeEach + void setUp() { + processor = new TmdbTvSeriesItemProcessor(contentRepository, new ObjectMapper()); + } + + @Test + @DisplayName("이미 존재하는 TV 시리즈면 null을 반환한다") + void process_duplicate_returnsNull() throws Exception { + // given + TmdbTvDto dto = new TmdbTvDto(1L, "브레이킹 배드", "Breaking Bad", "overview", + "/poster.jpg", List.of(18L), "2008-01-20", 9.5, "en"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(true); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("새로운 TV 시리즈면 ContentWithMetaData를 반환한다") + void process_newTvSeries_returnsContentWithMetaData() throws Exception { + // given + TmdbTvDto dto = new TmdbTvDto(1L, "브레이킹 배드", "Breaking Bad", "overview", + "/poster.jpg", List.of(18L), "2008-01-20", 9.5, "en"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.content().getTitle()).isEqualTo("브레이킹 배드"); + assertThat(result.content().getType()).isEqualTo(ContentType.TV_SERIES); + assertThat(result.content().getSource()).isEqualTo(ContentSource.TMDB); + assertThat(result.thumbnailUrl()).isEqualTo("https://image.tmdb.org/t/p/w500/poster.jpg"); + } + + @Test + @DisplayName("posterPath가 null이면 thumbnailUrl도 null이다") + void process_nullPosterPath_thumbnailUrlIsNull() throws Exception { + // given + TmdbTvDto dto = new TmdbTvDto(2L, "포스터없는시리즈", "No Poster", "overview", + null, List.of(), "2020-01-01", 7.0, "ko"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "2")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.thumbnailUrl()).isNull(); + } + + @Test + @DisplayName("genreIds가 null이어도 예외 없이 tagNames가 빈 리스트로 처리된다") + void process_nullGenreIds_tagNamesEmpty() throws Exception { + // given + TmdbTvDto dto = new TmdbTvDto(3L, "장르없는시리즈", "NoGenre", "overview", + null, null, "2020-01-01", 7.0, "ko"); + given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "3")).willReturn(false); + + // when + ContentWithMetaData result = processor.process(dto); + + // then + assertThat(result).isNotNull(); + assertThat(result.tagNames()).isEmpty(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/reader/SportsDbDayItemReaderTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/reader/SportsDbDayItemReaderTest.java new file mode 100644 index 00000000..84b39160 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/reader/SportsDbDayItemReaderTest.java @@ -0,0 +1,126 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventListResponse; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.test.MetaDataInstanceFactory; +import org.springframework.retry.support.RetryTemplate; + +@ExtendWith(MockitoExtension.class) +class SportsDbDayItemReaderTest { + + @Mock + private SportsDbApiClient sportsDbApiClient; + + private SportsDbDayItemReader reader; + + @BeforeEach + void setUp() { + // 운영 설정(BatchConfig)과 동일하게 최대 2회 시도하되, 테스트 속도를 위해 + // 백오프(운영은 200ms)는 두지 않는다. + RetryTemplate retryTemplate = RetryTemplate.builder() + .maxAttempts(2) + .noBackoff() + .retryOn(Exception.class) + .build(); + reader = new SportsDbDayItemReader(sportsDbApiClient, retryTemplate); + } + + private StepExecution createStepExecution(String date) { + return MetaDataInstanceFactory.createStepExecution( + new JobParametersBuilder() + .addString("date", date) + .toJobParameters() + ); + } + + @Test + @DisplayName("date 파라미터가 없으면 예외가 발생한다") + void beforeStep_missingDate_throwsException() { + // given + StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution(); + + // when, then + assertThatThrownBy(() -> reader.beforeStep(stepExecution)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("경기가 있는 날짜면 버퍼에 적재되어 read()로 반환된다") + void read_eventsExist_returnsEvents() { + // given + SportsDbEventDto event = new SportsDbEventDto("1", "Arsenal vs Chelsea", + null, "2024-12-26", "English Premier League", + "Soccer", "Arsenal", "Chelsea", "2", "1"); + given(sportsDbApiClient.fetchEventsByDay(anyString(), eq("2024-12-26"))) + .willReturn(new SportsDbEventListResponse(List.of(event))) + .willReturn(null); + reader.beforeStep(createStepExecution("2024-12-26")); + + // when, then + assertThat(reader.read()).isEqualTo(event); + } + + @Test + @DisplayName("경기가 없는 날짜면 read()가 null을 반환한다") + void read_noEvents_returnsNull() { + // given + given(sportsDbApiClient.fetchEventsByDay(anyString(), eq("2024-07-01"))) + .willReturn(null); + reader.beforeStep(createStepExecution("2024-07-01")); + + // when, then + assertThat(reader.read()).isNull(); + } + + @Test + @DisplayName("리그 조회가 첫 시도에 실패해도 재시도로 성공하면 데이터가 포함된다") + void read_leagueFetchFailsOnce_retriesAndSucceeds() { + // given + SportsDbEventDto event = new SportsDbEventDto("1", "Arsenal vs Chelsea", + null, "2024-12-26", "English Premier League", + "Soccer", "Arsenal", "Chelsea", "2", "1"); + given(sportsDbApiClient.fetchEventsByDay(eq("4328"), eq("2024-12-26"))) + .willThrow(new RuntimeException("일시적 오류")) + .willReturn(new SportsDbEventListResponse(List.of(event))); + reader.beforeStep(createStepExecution("2024-12-26")); + + // when, then + assertThat(reader.read()).isEqualTo(event); + } + + @Test + @DisplayName("한 리그가 재시도까지 모두 실패해도 다른 리그 데이터는 유지된다") + void read_leagueFetchFailsAfterRetries_otherLeaguesPreserved() { + // given + SportsDbEventDto event = new SportsDbEventDto("2", "Real Madrid vs Barcelona", + null, "2024-12-26", "Spanish La Liga", + "Soccer", "Real Madrid", "Barcelona", "3", "1"); + // EPL(leagueId=4328)은 재시도까지 계속 실패한다. + given(sportsDbApiClient.fetchEventsByDay(eq("4328"), eq("2024-12-26"))) + .willThrow(new RuntimeException("지속 오류")); + // LA_LIGA(leagueId=4335)는 정상 응답한다. + given(sportsDbApiClient.fetchEventsByDay(eq("4335"), eq("2024-12-26"))) + .willReturn(new SportsDbEventListResponse(List.of(event))); + reader.beforeStep(createStepExecution("2024-12-26")); + + // when, then + assertThat(reader.read()).isEqualTo(event); + assertThat(reader.read()).isNull(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/reader/SportsDbEventItemReaderTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/reader/SportsDbEventItemReaderTest.java new file mode 100644 index 00000000..29357212 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/reader/SportsDbEventItemReaderTest.java @@ -0,0 +1,122 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; +import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventListResponse; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.test.MetaDataInstanceFactory; +import org.springframework.retry.support.RetryTemplate; + +@ExtendWith(MockitoExtension.class) +class SportsDbEventItemReaderTest { + + @Mock + private SportsDbApiClient sportsDbApiClient; + + private SportsDbEventItemReader reader; + + @BeforeEach + void setUp() { + // 운영 설정(BatchConfig)과 동일하게 최대 2회 시도하되, 테스트 속도를 위해 + // 백오프(운영은 200ms)는 두지 않는다. + RetryTemplate retryTemplate = RetryTemplate.builder() + .maxAttempts(2) + .noBackoff() + .retryOn(Exception.class) + .build(); + reader = new SportsDbEventItemReader(sportsDbApiClient, retryTemplate); + } + + private StepExecution createStepExecution(String leagueId, String season) { + return MetaDataInstanceFactory.createStepExecution( + new JobParametersBuilder() + .addString("leagueId", leagueId) + .addString("season", season) + .toJobParameters() + ); + } + + @Test + @DisplayName("leagueId, season 파라미터가 없으면 예외가 발생한다") + void beforeStep_missingParams_throwsException() { + // given + StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution(); + + // when, then + assertThatThrownBy(() -> reader.beforeStep(stepExecution)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("경기가 있으면 버퍼에 적재되어 read()로 반환된다") + void read_eventsExist_returnsEvents() { + // given + SportsDbEventDto event = new SportsDbEventDto("1", "Arsenal vs Chelsea", + null, "2023-08-12", "English Premier League", + "Soccer", "Arsenal", "Chelsea", "2", "1"); + given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) + .willReturn(new SportsDbEventListResponse(List.of(event))); + + // when + reader.beforeStep(createStepExecution("4328", "2023-2024")); + + // then + assertThat(reader.read()).isEqualTo(event); + assertThat(reader.read()).isNull(); + } + + @Test + @DisplayName("경기가 없으면 read()가 null을 반환한다") + void read_noEvents_returnsNull() { + // given + given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")).willReturn(null); + + // when + reader.beforeStep(createStepExecution("4328", "2023-2024")); + + // then + assertThat(reader.read()).isNull(); + } + + @Test + @DisplayName("조회가 첫 시도에 실패해도 재시도로 성공하면 데이터가 포함된다") + void beforeStep_fetchFailsOnce_retriesAndSucceeds() { + // given + SportsDbEventDto event = new SportsDbEventDto("1", "Arsenal vs Chelsea", + null, "2023-08-12", "English Premier League", + "Soccer", "Arsenal", "Chelsea", "2", "1"); + given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) + .willThrow(new RuntimeException("일시적 오류")) + .willReturn(new SportsDbEventListResponse(List.of(event))); + + // when + reader.beforeStep(createStepExecution("4328", "2023-2024")); + + // then + assertThat(reader.read()).isEqualTo(event); + } + + @Test + @DisplayName("재시도까지 모두 실패하면 beforeStep이 예외를 전파한다") + void beforeStep_fetchFailsAfterRetries_propagatesException() { + // given + given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) + .willThrow(new RuntimeException("지속 오류")); + + // when, then + assertThatThrownBy(() -> reader.beforeStep(createStepExecution("4328", "2023-2024"))) + .isInstanceOf(RuntimeException.class); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/reader/TmdbMovieItemReaderTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/reader/TmdbMovieItemReaderTest.java new file mode 100644 index 00000000..44f903b8 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/reader/TmdbMovieItemReaderTest.java @@ -0,0 +1,158 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieListResponse; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.test.MetaDataInstanceFactory; +import org.springframework.retry.support.RetryTemplate; + +@ExtendWith(MockitoExtension.class) +class TmdbMovieItemReaderTest { + + @Mock + private TmdbApiClient tmdbApiClient; + + private TmdbMovieItemReader reader; + + @BeforeEach + void setUp() { + // 운영 설정(BatchConfig)과 동일하게 최대 2회 시도하되, 테스트 속도를 위해 + // 백오프(운영은 200ms)는 두지 않는다. + RetryTemplate retryTemplate = RetryTemplate.builder() + .maxAttempts(2) + .noBackoff() + .retryOn(Exception.class) + .build(); + reader = new TmdbMovieItemReader(tmdbApiClient, retryTemplate); + } + + private StepExecution createStepExecution(String startPage, String endPage) { + return MetaDataInstanceFactory.createStepExecution( + new JobParametersBuilder() + .addString("startPage", startPage) + .addString("endPage", endPage) + .toJobParameters() + ); + } + + @Test + @DisplayName("startPage, endPage 파라미터가 없으면 예외가 발생한다") + void beforeStep_missingParams_throwsException() { + // given + StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution(); + + // when, then + assertThatThrownBy(() -> reader.beforeStep(stepExecution)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("1페이지 데이터를 순서대로 읽는다") + void read_returnsItemsInOrder() throws Exception { + // given + TmdbMovieDto movie1 = new TmdbMovieDto(1L, "영화1", "Movie1", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + TmdbMovieDto movie2 = new TmdbMovieDto(2L, "영화2", "Movie2", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(movie1, movie2), 1, 2)); + + // when + reader.beforeStep(createStepExecution("1", "1")); + + // then + assertThat(reader.read()).isEqualTo(movie1); + assertThat(reader.read()).isEqualTo(movie2); + assertThat(reader.read()).isNull(); + } + + @Test + @DisplayName("title이 빈 영화는 필터링된다") + void read_emptyTitle_filtered() throws Exception { + // given + TmdbMovieDto validMovie = new TmdbMovieDto(1L, "영화1", "Movie1", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + TmdbMovieDto emptyTitle = new TmdbMovieDto(2L, "", "NoTitle", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(validMovie, emptyTitle), 1, 2)); + + // when + reader.beforeStep(createStepExecution("1", "1")); + + // then + assertThat(reader.read()).isEqualTo(validMovie); + assertThat(reader.read()).isNull(); + } + + @Test + @DisplayName("endPage가 MAX_PAGE(500)를 초과하면 500으로 클램핑되어 501페이지부터는 API를 호출하지 않는다") + void beforeStep_endPageExceedsMax_clampsTo500() throws Exception { + // given + // startPage를 501로 시작시켜, 클램핑된 endPage(500)를 즉시 넘어서는지로 검증한다. + + // when + reader.beforeStep(createStepExecution("501", "9999")); + + // then + assertThat(reader.read()).isNull(); + verify(tmdbApiClient, never()).fetchMovies(anyInt()); + } + + @Test + @DisplayName("응답 results가 비어있으면 totalPages와 무관하게 즉시 종료하고 이후 페이지는 호출하지 않는다") + void read_emptyResults_stopsImmediatelyWithoutCallingLaterPages() throws Exception { + // given + // totalPages를 크게 줘서, endPage 도달이 아니라 빈 results 자체 때문에 멈추는지 검증한다. + given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(), 500, 0)); + + // when + reader.beforeStep(createStepExecution("1", "500")); + + // then + assertThat(reader.read()).isNull(); + verify(tmdbApiClient, never()).fetchMovies(2); + } + + @Test + @DisplayName("페이지 조회가 첫 시도에 실패해도 재시도로 성공하면 데이터가 포함된다") + void beforeStep_pageFetchFailsOnce_retriesAndSucceeds() throws Exception { + // given + TmdbMovieDto movie = new TmdbMovieDto(1L, "영화", "Movie", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchMovies(1)) + .willThrow(new RuntimeException("일시적 오류")) + .willReturn(new TmdbMovieListResponse(1, List.of(movie), 1, 1)); + + // when + reader.beforeStep(createStepExecution("1", "1")); + + // then + assertThat(reader.read()).isEqualTo(movie); + } + + @Test + @DisplayName("한 페이지가 재시도까지 모두 실패해도 다른 페이지 데이터는 유지된다") + void beforeStep_pageFetchFailsAfterRetries_otherPagesPreserved() throws Exception { + // given + TmdbMovieDto movie = new TmdbMovieDto(2L, "영화2", "Movie2", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchMovies(1)).willThrow(new RuntimeException("지속 오류")); + given(tmdbApiClient.fetchMovies(2)).willReturn(new TmdbMovieListResponse(2, List.of(movie), 2, 1)); + + // when + reader.beforeStep(createStepExecution("1", "2")); + + // then + assertThat(reader.read()).isEqualTo(movie); + assertThat(reader.read()).isNull(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/reader/TmdbTvSeriesItemReaderTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/reader/TmdbTvSeriesItemReaderTest.java new file mode 100644 index 00000000..6c4ae761 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/reader/TmdbTvSeriesItemReaderTest.java @@ -0,0 +1,157 @@ +package com.codeit.team5.mopl.content.batch.reader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; +import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvListResponse; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.test.MetaDataInstanceFactory; +import org.springframework.retry.support.RetryTemplate; + +@ExtendWith(MockitoExtension.class) +class TmdbTvSeriesItemReaderTest { + + @Mock + private TmdbApiClient tmdbApiClient; + + private TmdbTvSeriesItemReader reader; + + @BeforeEach + void setUp() { + // 운영 설정(BatchConfig)과 동일하게 최대 2회 시도하되, 테스트 속도를 위해 + // 백오프(운영은 200ms)는 두지 않는다. + RetryTemplate retryTemplate = RetryTemplate.builder() + .maxAttempts(2) + .noBackoff() + .retryOn(Exception.class) + .build(); + reader = new TmdbTvSeriesItemReader(tmdbApiClient, retryTemplate); + } + + private StepExecution createStepExecution(String startPage, String endPage) { + return MetaDataInstanceFactory.createStepExecution( + new JobParametersBuilder() + .addString("startPage", startPage) + .addString("endPage", endPage) + .toJobParameters() + ); + } + + @Test + @DisplayName("startPage, endPage 파라미터가 없으면 예외가 발생한다") + void beforeStep_missingParams_throwsException() { + // given + StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution(); + + // when, then + assertThatThrownBy(() -> reader.beforeStep(stepExecution)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("1페이지 데이터를 순서대로 읽는다") + void read_returnsItemsInOrder() throws Exception { + // given + TmdbTvDto tv1 = new TmdbTvDto(1L, "시리즈1", "Series1", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + TmdbTvDto tv2 = new TmdbTvDto(2L, "시리즈2", "Series2", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(tv1, tv2), 1, 2)); + + // when + reader.beforeStep(createStepExecution("1", "1")); + + // then + assertThat(reader.read()).isEqualTo(tv1); + assertThat(reader.read()).isEqualTo(tv2); + assertThat(reader.read()).isNull(); + } + + @Test + @DisplayName("name이 빈 시리즈는 필터링된다") + void read_emptyName_filtered() throws Exception { + // given + TmdbTvDto validTv = new TmdbTvDto(1L, "시리즈1", "Series1", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + TmdbTvDto emptyName = new TmdbTvDto(2L, "", "NoName", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(validTv, emptyName), 1, 2)); + + // when + reader.beforeStep(createStepExecution("1", "1")); + + // then + assertThat(reader.read()).isEqualTo(validTv); + assertThat(reader.read()).isNull(); + } + + @Test + @DisplayName("endPage가 MAX_PAGE(500)를 초과하면 500으로 클램핑되어 501페이지부터는 API를 호출하지 않는다") + void beforeStep_endPageExceedsMax_clampsTo500() throws Exception { + // given + // startPage를 501로 시작시켜, 클램핑된 endPage(500)를 즉시 넘어서는지로 검증한다. + + // when + reader.beforeStep(createStepExecution("501", "9999")); + + // then + assertThat(reader.read()).isNull(); + verify(tmdbApiClient, never()).fetchTvSeries(anyInt()); + } + + @Test + @DisplayName("응답 results가 비어있으면 totalPages와 무관하게 즉시 종료하고 이후 페이지는 호출하지 않는다") + void read_emptyResults_stopsImmediatelyWithoutCallingLaterPages() throws Exception { + // given + given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(), 500, 0)); + + // when + reader.beforeStep(createStepExecution("1", "500")); + + // then + assertThat(reader.read()).isNull(); + verify(tmdbApiClient, never()).fetchTvSeries(2); + } + + @Test + @DisplayName("페이지 조회가 첫 시도에 실패해도 재시도로 성공하면 데이터가 포함된다") + void beforeStep_pageFetchFailsOnce_retriesAndSucceeds() throws Exception { + // given + TmdbTvDto tv = new TmdbTvDto(1L, "시리즈", "Series", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchTvSeries(1)) + .willThrow(new RuntimeException("일시적 오류")) + .willReturn(new TmdbTvListResponse(1, List.of(tv), 1, 1)); + + // when + reader.beforeStep(createStepExecution("1", "1")); + + // then + assertThat(reader.read()).isEqualTo(tv); + } + + @Test + @DisplayName("한 페이지가 재시도까지 모두 실패해도 다른 페이지 데이터는 유지된다") + void beforeStep_pageFetchFailsAfterRetries_otherPagesPreserved() throws Exception { + // given + TmdbTvDto tv = new TmdbTvDto(2L, "시리즈2", "Series2", "desc", null, List.of(), "2024-01-01", 7.0, "ko"); + given(tmdbApiClient.fetchTvSeries(1)).willThrow(new RuntimeException("지속 오류")); + given(tmdbApiClient.fetchTvSeries(2)).willReturn(new TmdbTvListResponse(2, List.of(tv), 2, 1)); + + // when + reader.beforeStep(createStepExecution("1", "2")); + + // then + assertThat(reader.read()).isEqualTo(tv); + assertThat(reader.read()).isNull(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/retry/SelectiveRetryPolicyTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/retry/SelectiveRetryPolicyTest.java new file mode 100644 index 00000000..52a1f340 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/retry/SelectiveRetryPolicyTest.java @@ -0,0 +1,90 @@ +package com.codeit.team5.mopl.content.batch.retry; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.retry.RetryContext; +import org.springframework.retry.context.RetryContextSupport; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +class SelectiveRetryPolicyTest { + + private final SelectiveRetryPolicy policy = new SelectiveRetryPolicy(3); + + private RetryContext contextWith(Throwable t) { + RetryContextSupport context = new RetryContextSupport(null); + context.registerThrowable(t); + return context; + } + + @Test + @DisplayName("네트워크 자체 실패는 재시도한다") + void canRetry_webClientRequestException_returnsTrue() { + // given + WebClientRequestException e = new WebClientRequestException( + new RuntimeException("connect timeout"), HttpMethod.GET, URI.create("http://test"), new HttpHeaders()); + + // when, then + assertThat(policy.canRetry(contextWith(e))).isTrue(); + } + + @Test + @DisplayName("DB 일시적 오류는 재시도한다") + void canRetry_transientDataAccessException_returnsTrue() { + // given + QueryTimeoutException e = new QueryTimeoutException("timeout"); + + // when, then + assertThat(policy.canRetry(contextWith(e))).isTrue(); + } + + @Test + @DisplayName("5xx 응답은 재시도한다") + void canRetry_5xxResponse_returnsTrue() { + // given + WebClientResponseException e = WebClientResponseException.create(500, "Internal Server Error", null, null, null); + + // when, then + assertThat(policy.canRetry(contextWith(e))).isTrue(); + } + + @Test + @DisplayName("429 응답은 재시도한다") + void canRetry_429_returnsTrue() { + // given + WebClientResponseException e = WebClientResponseException.create(429, "Too Many Requests", null, null, null); + + // when, then + assertThat(policy.canRetry(contextWith(e))).isTrue(); + } + + @Test + @DisplayName("429가 아닌 4xx 응답은 재시도하지 않는다") + void canRetry_4xxOtherThan429_returnsFalse() { + // given + WebClientResponseException e = WebClientResponseException.create(400, "Bad Request", null, null, null); + + // when, then + assertThat(policy.canRetry(contextWith(e))).isFalse(); + } + + @Test + @DisplayName("재시도 대상 예외라도 maxAttempts를 초과하면 재시도하지 않는다") + void canRetry_maxAttemptsExceeded_returnsFalse() { + // given + RetryContextSupport context = new RetryContextSupport(null); + WebClientResponseException e = WebClientResponseException.create(503, "Service Unavailable", null, null, null); + context.registerThrowable(e); // 1회 + context.registerThrowable(e); // 2회 + context.registerThrowable(e); // 3회 (maxAttempts=3 도달) + + // when, then + assertThat(policy.canRetry(context)).isFalse(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/retry/SelectiveSkipPolicyTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/retry/SelectiveSkipPolicyTest.java new file mode 100644 index 00000000..03637786 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/retry/SelectiveSkipPolicyTest.java @@ -0,0 +1,66 @@ +package com.codeit.team5.mopl.content.batch.retry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.step.skip.SkipLimitExceededException; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +class SelectiveSkipPolicyTest { + + private final SelectiveSkipPolicy policy = new SelectiveSkipPolicy(3); + + @Test + @DisplayName("5xx 응답(시스템 전역 문제로 간주)은 스킵하지 않는다") + void shouldSkip_5xxResponse_returnsFalse() throws Exception { + // given + WebClientResponseException e = WebClientResponseException.create(500, "Internal Server Error", null, null, null); + + // when, then + assertThat(policy.shouldSkip(e, 0)).isFalse(); + } + + @Test + @DisplayName("DB 일시적 오류(시스템 전역 문제로 간주)는 스킵하지 않는다") + void shouldSkip_transientDataAccessException_returnsFalse() throws Exception { + // given + QueryTimeoutException e = new QueryTimeoutException("timeout"); + + // when, then + assertThat(policy.shouldSkip(e, 0)).isFalse(); + } + + @Test + @DisplayName("429가 아닌 4xx 응답(대상 항목만의 문제)은 한도 내에서 스킵한다") + void shouldSkip_4xxOtherThan429_returnsTrue() throws Exception { + // given + WebClientResponseException e = WebClientResponseException.create(404, "Not Found", null, null, null); + + // when, then + assertThat(policy.shouldSkip(e, 0)).isTrue(); + } + + @Test + @DisplayName("스킵 대상 예외가 skipLimit에 도달하면 SkipLimitExceededException을 던진다") + void shouldSkip_limitExceeded_throwsException() { + // given + WebClientResponseException e = WebClientResponseException.create(404, "Not Found", null, null, null); + + // when, then + assertThatThrownBy(() -> policy.shouldSkip(e, 3)) + .isInstanceOf(SkipLimitExceededException.class); + } + + @Test + @DisplayName("skipCount가 한도를 넘어도 시스템 전역 문제면 예외 없이 스킵 불가만 반환한다") + void shouldSkip_limitExceededButTransientFailure_returnsFalseWithoutThrowing() throws Exception { + // given + WebClientResponseException e = WebClientResponseException.create(500, "Internal Server Error", null, null, null); + + // when, then + assertThat(policy.shouldSkip(e, 3)).isFalse(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java new file mode 100644 index 00000000..d4ec9040 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/batch/writer/ContentItemWriterTest.java @@ -0,0 +1,236 @@ +package com.codeit.team5.mopl.content.batch.writer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentSource; +import com.codeit.team5.mopl.content.entity.ContentTag; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.content.repository.ContentStatsRepository; +import com.codeit.team5.mopl.tag.entity.Tag; +import com.codeit.team5.mopl.tag.repository.TagRepository; +import java.util.List; +import java.util.UUID; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.Chunk; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ContentItemWriterTest { + + @Mock + private ContentRepository contentRepository; + @Mock + private ContentStatsRepository contentStatsRepository; + @Mock + private BinaryContentRepository binaryContentRepository; + @Mock + private TagRepository tagRepository; + + private ContentItemWriter writer; + + @BeforeEach + void setUp() { + writer = new ContentItemWriter(contentRepository, contentStatsRepository, + binaryContentRepository, tagRepository); + } + + @Test + @DisplayName("청크 내 콘텐츠를 saveAll로 일괄 저장한다") + void write_savesAllContents() throws Exception { + // given + Content content1 = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + Content content2 = Content.createByExternalSource(ContentType.MOVIE, "영화2", "desc", + ContentSource.TMDB, "2", null, "{}"); + ContentWithMetaData item1 = new ContentWithMetaData(content1, null, List.of()); + ContentWithMetaData item2 = new ContentWithMetaData(content2, null, List.of()); + given(contentRepository.saveAll(anyList())).willReturn(List.of(content1, content2)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + + // when + writer.write(new Chunk<>(List.of(item1, item2))); + + // then + verify(contentRepository).saveAll(anyList()); + verify(contentStatsRepository).saveAll(anyList()); + } + + @Test + @DisplayName("청크 내 externalId 중복이 있으면 첫 번째 항목만 저장한다") + void write_deduplicatesChunkByExternalId() throws Exception { + // given + Content content1 = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + Content content2 = Content.createByExternalSource(ContentType.MOVIE, "영화1-중복", "desc", + ContentSource.TMDB, "1", null, "{}"); + ContentWithMetaData item1 = new ContentWithMetaData(content1, null, List.of()); + ContentWithMetaData item2 = new ContentWithMetaData(content2, null, List.of()); + given(contentRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + + // when + writer.write(new Chunk<>(List.of(item1, item2))); + + // then + verify(contentRepository).saveAll(List.of(content1)); + } + + @Test + @DisplayName("썸네일 URL이 있으면 saveAll로 일괄 저장 후 content에 연결한다") + void write_savesThumbnailsAndAttachesToContent() throws Exception { + // given + Content content = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + ContentWithMetaData item = new ContentWithMetaData(content, "https://img.example.com/poster.jpg", List.of()); + BinaryContent savedThumbnail = BinaryContent.externalUrl("https://img.example.com/poster.jpg"); + given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + given(binaryContentRepository.saveAll(anyList())).willReturn(List.of(savedThumbnail)); + + // when + writer.write(new Chunk<>(List.of(item))); + + // then + verify(binaryContentRepository).saveAll(anyList()); + assertThat(content.getThumbnail()).isEqualTo(savedThumbnail); + } + + @Test + @DisplayName("썸네일 URL이 없으면 binaryContentRepository를 호출하지 않는다") + void write_skipsThumbnailWhenUrlIsNull() throws Exception { + // given + Content content = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + ContentWithMetaData item = new ContentWithMetaData(content, null, List.of()); + given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + + // when + writer.write(new Chunk<>(List.of(item))); + + // then + verify(binaryContentRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("서로 다른 콘텐츠가 같은 thumbnailUrl을 가지면 각각 별도의 BinaryContent가 연결된다") + void write_sameThumbnailUrl_attachesSeparateBinaryContentToEach() throws Exception { + // given + Content content1 = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + Content content2 = Content.createByExternalSource(ContentType.MOVIE, "영화2", "desc", + ContentSource.TMDB, "2", null, "{}"); + String sharedUrl = "https://img.example.com/default.jpg"; + ContentWithMetaData item1 = new ContentWithMetaData(content1, sharedUrl, List.of()); + ContentWithMetaData item2 = new ContentWithMetaData(content2, sharedUrl, List.of()); + BinaryContent savedThumbnail1 = BinaryContent.externalUrl(sharedUrl); + BinaryContent savedThumbnail2 = BinaryContent.externalUrl(sharedUrl); + given(contentRepository.saveAll(anyList())).willReturn(List.of(content1, content2)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + given(binaryContentRepository.saveAll(anyList())).willReturn(List.of(savedThumbnail1, savedThumbnail2)); + + // when + writer.write(new Chunk<>(List.of(item1, item2))); + + // then + assertThat(content1.getThumbnail()).isEqualTo(savedThumbnail1); + assertThat(content2.getThumbnail()).isEqualTo(savedThumbnail2); + assertThat(content1.getThumbnail()).isNotSameAs(content2.getThumbnail()); + } + + @Test + @DisplayName("이미 존재하는 태그면 재사용하고 새로 생성하지 않는다") + void write_existingTag_reusesWithoutCreatingNew() throws Exception { + // given + Content content = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + ReflectionTestUtils.setField(content, "id", UUID.randomUUID()); + ContentWithMetaData item = new ContentWithMetaData(content, null, List.of("액션")); + Tag existingTag = Tag.create("액션"); + ReflectionTestUtils.setField(existingTag, "id", UUID.randomUUID()); + given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + given(tagRepository.findByNameIn(List.of("액션"))).willReturn(List.of(existingTag)); + + // when + writer.write(new Chunk<>(List.of(item))); + + // then + verify(tagRepository, never()).saveAll(anyList()); + assertThat(content.getContentTags()) + .extracting(ContentTag::getTag) + .containsExactly(existingTag); + } + + @Test + @DisplayName("존재하지 않는 태그면 새로 생성하여 저장한다") + void write_newTag_createsAndSaves() throws Exception { + // given + Content content = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + ReflectionTestUtils.setField(content, "id", UUID.randomUUID()); + ContentWithMetaData item = new ContentWithMetaData(content, null, List.of("드라마")); + given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + given(tagRepository.findByNameIn(List.of("드라마"))).willReturn(List.of()); + given(tagRepository.saveAll(anyList())).willAnswer(invocation -> { + List newTags = invocation.getArgument(0); + newTags.forEach(tag -> ReflectionTestUtils.setField(tag, "id", UUID.randomUUID())); + return newTags; + }); + + // when + writer.write(new Chunk<>(List.of(item))); + + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(tagRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).extracting(Tag::getName).containsExactly("드라마"); + assertThat(content.getContentTags()) + .extracting(ct -> ct.getTag().getName()) + .containsExactly("드라마"); + } + + @Test + @DisplayName("기존 태그와 신규 태그가 섞여 있어도 모두 ContentTag로 연결된다") + void write_mixedExistingAndNewTags_allAttachedAsContentTag() throws Exception { + // given + Content content = Content.createByExternalSource(ContentType.MOVIE, "영화1", "desc", + ContentSource.TMDB, "1", null, "{}"); + ReflectionTestUtils.setField(content, "id", UUID.randomUUID()); + ContentWithMetaData item = new ContentWithMetaData(content, null, List.of("액션", "드라마")); + Tag existingTag = Tag.create("액션"); + ReflectionTestUtils.setField(existingTag, "id", UUID.randomUUID()); + given(contentRepository.saveAll(anyList())).willReturn(List.of(content)); + given(contentStatsRepository.saveAll(anyList())).willReturn(List.of()); + given(tagRepository.findByNameIn(List.of("액션", "드라마"))).willReturn(List.of(existingTag)); + given(tagRepository.saveAll(anyList())).willAnswer(invocation -> { + List newTags = invocation.getArgument(0); + newTags.forEach(tag -> ReflectionTestUtils.setField(tag, "id", UUID.randomUUID())); + return newTags; + }); + + // when + writer.write(new Chunk<>(List.of(item))); + + // then + assertThat(content.getContentTags()) + .extracting(ct -> ct.getTag().getName()) + .containsExactlyInAnyOrder("액션", "드라마"); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerTest.java index 24d5940d..1832384d 100644 --- a/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerTest.java @@ -1,17 +1,18 @@ package com.codeit.team5.mopl.content.controller; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.codeit.team5.mopl.TestGlobalExceptionHandlerConfig; import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter; -import com.codeit.team5.mopl.content.service.SportsDbContentService; -import com.codeit.team5.mopl.content.service.TmdbContentService; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.launch.JobLauncher; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -35,41 +36,73 @@ class ContentCollectionControllerTest { @Autowired private MockMvc mockMvc; - @MockitoBean - private TmdbContentService tmdbContentService; + @MockitoBean(name = "asyncJobLauncher") + private JobLauncher asyncJobLauncher; - @MockitoBean - private SportsDbContentService sportsDbContentService; + @MockitoBean(name = "tmdbMovieJob") + private Job tmdbMovieJob; + + @MockitoBean(name = "tmdbTvSeriesJob") + private Job tmdbTvSeriesJob; + + @MockitoBean(name = "sportsDbEventJob") + private Job sportsDbEventJob; + + @MockitoBean(name = "sportsDbDayJob") + private Job sportsDbDayJob; // --- TMDB 영화 수집 --- @Test @DisplayName("TMDB 영화 수집 요청이면 202 응답을 반환한다") void collectTmdbMovies_success() throws Exception { + // when, then mockMvc.perform(post("/api/admin/contents/collect/tmdb/movies") .param("startPage", "1") .param("endPage", "5")) .andExpect(status().isAccepted()); + } - verify(tmdbContentService).collectMovies(1, 5); + @Test + @DisplayName("Job 실행(launch) 자체가 실패하면 500 응답을 반환한다") + void collectTmdbMovies_jobLaunchFails_returnsInternalServerError() throws Exception { + // given + given(asyncJobLauncher.run(any(), any())) + .willThrow(new JobParametersInvalidException("잘못된 Job 파라미터")); + + // when, then + mockMvc.perform(post("/api/admin/contents/collect/tmdb/movies") + .param("startPage", "1") + .param("endPage", "5")) + .andExpect(status().isInternalServerError()); } @Test - @DisplayName("TMDB 영화 수집 파라미터 생략 시 기본값(1, 1)으로 202 응답을 반환한다") - void collectTmdbMovies_defaultParams_success() throws Exception { + @DisplayName("TMDB 영화 수집 파라미터 생략 시 400 응답을 반환한다") + void collectTmdbMovies_missingParams_returnsBadRequest() throws Exception { + // when, then mockMvc.perform(post("/api/admin/contents/collect/tmdb/movies")) - .andExpect(status().isAccepted()); - - verify(tmdbContentService).collectMovies(1, 1); + .andExpect(status().isBadRequest()); } @Test - @DisplayName("TMDB 영화 수집 중 예상하지 못한 예외가 발생하면 500 응답을 반환한다") - void collectTmdbMovies_unexpectedException_returnsInternalServerError() throws Exception { - doThrow(new IllegalStateException("unexpected")).when(tmdbContentService).collectMovies(1, 1); + @DisplayName("TMDB 영화 수집 시 startPage가 0 이하면 400 응답을 반환한다") + void collectTmdbMovies_invalidStartPage_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/tmdb/movies") + .param("startPage", "0") + .param("endPage", "5")) + .andExpect(status().isBadRequest()); + } - mockMvc.perform(post("/api/admin/contents/collect/tmdb/movies")) - .andExpect(status().isInternalServerError()); + @Test + @DisplayName("TMDB 영화 수집 시 endPage가 startPage보다 작으면 400 응답을 반환한다") + void collectTmdbMovies_endPageLessThanStartPage_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/tmdb/movies") + .param("startPage", "5") + .param("endPage", "3")) + .andExpect(status().isBadRequest()); } // --- TMDB TV 시리즈 수집 --- @@ -77,30 +110,29 @@ void collectTmdbMovies_unexpectedException_returnsInternalServerError() throws E @Test @DisplayName("TMDB TV 시리즈 수집 요청이면 202 응답을 반환한다") void collectTmdbTvSeries_success() throws Exception { + // when, then mockMvc.perform(post("/api/admin/contents/collect/tmdb/tv") .param("startPage", "2") .param("endPage", "10")) .andExpect(status().isAccepted()); - - verify(tmdbContentService).collectTvSeries(2, 10); } @Test - @DisplayName("TMDB TV 수집 파라미터 생략 시 기본값(1, 1)으로 202 응답을 반환한다") - void collectTmdbTvSeries_defaultParams_success() throws Exception { + @DisplayName("TMDB TV 수집 파라미터 생략 시 400 응답을 반환한다") + void collectTmdbTvSeries_missingParams_returnsBadRequest() throws Exception { + // when, then mockMvc.perform(post("/api/admin/contents/collect/tmdb/tv")) - .andExpect(status().isAccepted()); - - verify(tmdbContentService).collectTvSeries(1, 1); + .andExpect(status().isBadRequest()); } @Test - @DisplayName("TMDB TV 수집 중 예상하지 못한 예외가 발생하면 500 응답을 반환한다") - void collectTmdbTvSeries_unexpectedException_returnsInternalServerError() throws Exception { - doThrow(new IllegalStateException("unexpected")).when(tmdbContentService).collectTvSeries(1, 1); - - mockMvc.perform(post("/api/admin/contents/collect/tmdb/tv")) - .andExpect(status().isInternalServerError()); + @DisplayName("TMDB TV 수집 시 endPage가 startPage보다 작으면 400 응답을 반환한다") + void collectTmdbTvSeries_endPageLessThanStartPage_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/tmdb/tv") + .param("startPage", "10") + .param("endPage", "5")) + .andExpect(status().isBadRequest()); } // --- SportsDB 경기 수집 --- @@ -108,39 +140,85 @@ void collectTmdbTvSeries_unexpectedException_returnsInternalServerError() throws @Test @DisplayName("SportsDB 경기 수집 요청이면 202 응답을 반환한다") void collectSportsEvents_success() throws Exception { + // when, then mockMvc.perform(post("/api/admin/contents/collect/sports") .param("league", "EPL") .param("season", "2023-2024")) .andExpect(status().isAccepted()); - - verify(sportsDbContentService).collectEvents("4328", "2023-2024"); } -// @Test -// @DisplayName("SportsDB league 파라미터가 누락되면 500 응답을 반환한다") -// void collectSportsEvents_missingLeague_returnsError() throws Exception { -// mockMvc.perform(post("/api/admin/contents/collect/sports") -// .param("season", "2023-2024")) -// .andExpect(status().isInternalServerError()); -// } -// -// @Test -// @DisplayName("SportsDB season 파라미터가 누락되면 500 응답을 반환한다") -// void collectSportsEvents_missingSeason_returnsError() throws Exception { -// mockMvc.perform(post("/api/admin/contents/collect/sports") -// .param("league", "EPL")) -// .andExpect(status().isInternalServerError()); -// } + @Test + @DisplayName("SportsDB league 파라미터가 누락되면 400 응답을 반환한다") + void collectSportsEvents_missingLeague_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/sports") + .param("season", "2023-2024")) + .andExpect(status().isBadRequest()); + } @Test - @DisplayName("SportsDB 경기 수집 중 예상하지 못한 예외가 발생하면 500 응답을 반환한다") - void collectSportsEvents_unexpectedException_returnsInternalServerError() throws Exception { - doThrow(new IllegalStateException("unexpected")) - .when(sportsDbContentService).collectEvents("4328", "2023-2024"); + @DisplayName("SportsDB season 파라미터가 누락되면 400 응답을 반환한다") + void collectSportsEvents_missingSeason_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/sports") + .param("league", "EPL")) + .andExpect(status().isBadRequest()); + } + @Test + @DisplayName("SportsDB season 형식이 YYYY-YYYY가 아니면 400 응답을 반환한다") + void collectSportsEvents_invalidSeasonFormat_returnsBadRequest() throws Exception { + // when, then mockMvc.perform(post("/api/admin/contents/collect/sports") .param("league", "EPL") + .param("season", "2023/2024")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("SportsDB league 값이 유효하지 않으면 400 응답을 반환한다") + void collectSportsEvents_invalidLeague_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/sports") + .param("league", "INVALID_LEAGUE") .param("season", "2023-2024")) - .andExpect(status().isInternalServerError()); + .andExpect(status().isBadRequest()); + } + + // --- SportsDB 일별 경기 수집 --- + + @Test + @DisplayName("SportsDB 일별 경기 수집 요청이면 202 응답을 반환한다") + void collectSportsEventsByDay_success() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/sports/day") + .param("date", "2024-12-26")) + .andExpect(status().isAccepted()); + } + + @Test + @DisplayName("SportsDB 일별 경기 수집 시 date 파라미터가 누락되면 400 응답을 반환한다") + void collectSportsEventsByDay_missingDate_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/sports/day")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("SportsDB 일별 경기 수집 시 date 형식이 YYYY-MM-DD가 아니면 400 응답을 반환한다") + void collectSportsEventsByDay_invalidDateFormat_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/sports/day") + .param("date", "2024/12/26")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("SportsDB 일별 경기 수집 시 실존하지 않는 날짜면 400 응답을 반환한다") + void collectSportsEventsByDay_nonExistentDate_returnsBadRequest() throws Exception { + // when, then + mockMvc.perform(post("/api/admin/contents/collect/sports/day") + .param("date", "2024-02-31")) + .andExpect(status().isBadRequest()); } } diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java index d754c536..543287ad 100644 --- a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java @@ -76,7 +76,7 @@ class ContentControllerTest { @Test @DisplayName("정상적인 콘텐츠 생성 요청이면 생성된 콘텐츠와 201 응답을 반환한다") void postContent_success() throws Exception { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( ContentType.MOVIE, "테스트 영화", @@ -108,7 +108,7 @@ void postContent_success() throws Exception { new byte[]{1, 2, 3} ); - // When & Then + // when, then mockMvc.perform(multipart("/api/contents") .file(requestPart) .file(thumbnailPart)) @@ -138,7 +138,7 @@ void postContent_success() throws Exception { @Test @DisplayName("thumbnail 없이 콘텐츠 생성 요청이면 201 응답을 반환한다") void postContent_withoutThumbnail_success() throws Exception { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( ContentType.TV_SERIES, "테스트 드라마", @@ -162,7 +162,7 @@ void postContent_withoutThumbnail_success() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart("/api/contents") .file(requestPart)) .andExpect(status().isCreated()) @@ -185,7 +185,7 @@ void postContent_withoutThumbnail_success() throws Exception { @Test @DisplayName("콘텐츠 타입이 누락되면 400 검증 실패 응답을 반환한다") void postContent_missingType_returnsBadRequest() throws Exception { - // Given + // given String requestJson = """ { "title": "테스트 영화", @@ -196,7 +196,7 @@ void postContent_missingType_returnsBadRequest() throws Exception { "request", "", MediaType.APPLICATION_JSON_VALUE, requestJson.getBytes(StandardCharsets.UTF_8) ); - // When & Then + // when, then mockMvc.perform(multipart("/api/contents") .file(requestPart)) .andExpect(status().isBadRequest()) @@ -210,7 +210,7 @@ void postContent_missingType_returnsBadRequest() throws Exception { @Test @DisplayName("제목이 공백이면 400 검증 실패 응답을 반환한다") void postContent_blankTitle_returnsBadRequest() throws Exception { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( ContentType.MOVIE, " ", @@ -222,7 +222,7 @@ void postContent_blankTitle_returnsBadRequest() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart("/api/contents") .file(requestPart)) .andExpect(status().isBadRequest()) @@ -236,7 +236,7 @@ void postContent_blankTitle_returnsBadRequest() throws Exception { @Test @DisplayName("태그 목록이 비어있으면 400 검증 실패 응답을 반환한다") void postContent_emptyTags_returnsBadRequest() throws Exception { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( ContentType.MOVIE, "테스트 영화", @@ -248,7 +248,7 @@ void postContent_emptyTags_returnsBadRequest() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart("/api/contents") .file(requestPart)) .andExpect(status().isBadRequest()) @@ -262,7 +262,7 @@ void postContent_emptyTags_returnsBadRequest() throws Exception { @Test @DisplayName("예상하지 못한 서비스 예외가 발생하면 500 오류 응답을 반환한다") void postContent_unexpectedException_returnsInternalServerError() throws Exception { - // Given + // given ContentCreateRequest request = new ContentCreateRequest( ContentType.MOVIE, "테스트 영화", @@ -277,7 +277,7 @@ void postContent_unexpectedException_returnsInternalServerError() throws Excepti objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart("/api/contents") .file(requestPart)) .andExpect(status().isInternalServerError()) @@ -289,7 +289,7 @@ void postContent_unexpectedException_returnsInternalServerError() throws Excepti @Test @DisplayName("정상적인 콘텐츠 수정 요청이면 수정된 콘텐츠와 200 응답을 반환한다") void patchContent_success() throws Exception { - // Given + // given UUID contentId = UUID.fromString("11111111-1111-1111-1111-111111111111"); ContentUpdateRequest request = new ContentUpdateRequest( "수정된 영화", @@ -318,7 +318,7 @@ void patchContent_success() throws Exception { new byte[]{4, 5, 6} ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart) .file(thumbnailPart)) @@ -345,7 +345,7 @@ void patchContent_success() throws Exception { @Test @DisplayName("thumbnail 없이 콘텐츠 수정 요청이면 200 응답을 반환한다") void patchContent_withoutThumbnail_success() throws Exception { - // Given + // given UUID contentId = UUID.fromString("22222222-2222-2222-2222-222222222222"); ContentUpdateRequest request = new ContentUpdateRequest( "수정된 드라마", @@ -370,7 +370,7 @@ void patchContent_withoutThumbnail_success() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart)) .andExpect(status().isOk()) @@ -384,7 +384,7 @@ void patchContent_withoutThumbnail_success() throws Exception { @Test @DisplayName("제목이 공백이면 400 검증 실패 응답을 반환한다") void patchContent_blankTitle_returnsBadRequest() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); ContentUpdateRequest request = new ContentUpdateRequest(" ", null, List.of("액션")); MockMultipartFile requestPart = new MockMultipartFile( @@ -392,7 +392,7 @@ void patchContent_blankTitle_returnsBadRequest() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart)) .andExpect(status().isBadRequest()) @@ -406,7 +406,7 @@ void patchContent_blankTitle_returnsBadRequest() throws Exception { @Test @DisplayName("태그가 null이면 400 검증 실패 응답을 반환한다") void patchContent_nullTags_returnsBadRequest() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); String requestJson = """ { @@ -417,7 +417,7 @@ void patchContent_nullTags_returnsBadRequest() throws Exception { "request", "", MediaType.APPLICATION_JSON_VALUE, requestJson.getBytes(StandardCharsets.UTF_8) ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart)) .andExpect(status().isBadRequest()) @@ -431,7 +431,7 @@ void patchContent_nullTags_returnsBadRequest() throws Exception { @Test @DisplayName("태그 목록이 비어있으면 400 검증 실패 응답을 반환한다") void patchContent_emptyTags_returnsBadRequest() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of()); MockMultipartFile requestPart = new MockMultipartFile( @@ -439,7 +439,7 @@ void patchContent_emptyTags_returnsBadRequest() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart)) .andExpect(status().isBadRequest()) @@ -453,7 +453,7 @@ void patchContent_emptyTags_returnsBadRequest() throws Exception { @Test @DisplayName("태그 항목이 공백이면 400 검증 실패 응답을 반환한다") void patchContent_blankTagItem_returnsBadRequest() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of(" ")); MockMultipartFile requestPart = new MockMultipartFile( @@ -461,7 +461,7 @@ void patchContent_blankTagItem_returnsBadRequest() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart)) .andExpect(status().isBadRequest()) @@ -475,7 +475,7 @@ void patchContent_blankTagItem_returnsBadRequest() throws Exception { @Test @DisplayName("존재하지 않는 콘텐츠 수정 요청이면 404 응답을 반환한다") void patchContent_notFound_returnsNotFound() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("액션")); given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) @@ -486,7 +486,7 @@ void patchContent_notFound_returnsNotFound() throws Exception { objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart)) .andExpect(status().isNotFound()) @@ -499,7 +499,7 @@ void patchContent_notFound_returnsNotFound() throws Exception { @Test @DisplayName("예상하지 못한 서비스 예외가 발생하면 500 오류 응답을 반환한다") void patchContent_unexpectedException_returnsInternalServerError() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("액션")); given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) @@ -510,7 +510,7 @@ void patchContent_unexpectedException_returnsInternalServerError() throws Except objectMapper.writeValueAsBytes(request) ); - // When & Then + // when, then mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{contentId}", contentId) .file(requestPart)) .andExpect(status().isInternalServerError()) @@ -522,7 +522,7 @@ void patchContent_unexpectedException_returnsInternalServerError() throws Except @Test @DisplayName("존재하는 콘텐츠 ID로 조회하면 콘텐츠와 200 응답을 반환한다") void getContent_success() throws Exception { - // Given + // given UUID contentId = UUID.fromString("11111111-1111-1111-1111-111111111111"); ContentResponse response = new ContentResponse( contentId, @@ -536,7 +536,7 @@ void getContent_success() throws Exception { given(contentService.findById(contentId)).willReturn(response); - // When & Then + // when, then mockMvc.perform(get("/api/contents/{contentId}", contentId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(contentId.toString())) @@ -556,11 +556,11 @@ void getContent_success() throws Exception { @Test @DisplayName("존재하지 않는 콘텐츠 ID로 조회하면 404 응답을 반환한다") void getContent_notFound_returnsNotFound() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); given(contentService.findById(contentId)).willThrow(new ContentNotFoundException(contentId)); - // When & Then + // when, then mockMvc.perform(get("/api/contents/{contentId}", contentId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")) @@ -572,11 +572,11 @@ void getContent_notFound_returnsNotFound() throws Exception { @Test @DisplayName("단건 조회 중 예상하지 못한 예외가 발생하면 500 응답을 반환한다") void getContent_unexpectedException_returnsInternalServerError() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); given(contentService.findById(contentId)).willThrow(new IllegalStateException("unexpected")); - // When & Then + // when, then mockMvc.perform(get("/api/contents/{contentId}", contentId)) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) @@ -587,7 +587,7 @@ void getContent_unexpectedException_returnsInternalServerError() throws Exceptio @Test @DisplayName("필수 파라미터만으로 콘텐츠 목록을 조회하면 200 응답을 반환한다") void getContents_success() throws Exception { - // Given + // given ContentResponse item = new ContentResponse( UUID.randomUUID(), ContentType.MOVIE, @@ -605,7 +605,7 @@ void getContents_success() throws Exception { given(contentService.findContents(any(ContentCursorRequest.class))).willReturn(response); - // When & Then + // when, then mockMvc.perform(get("/api/contents") .param("limit", "20") .param("sortDirection", "DESCENDING") @@ -624,7 +624,7 @@ void getContents_success() throws Exception { @Test @DisplayName("모든 필터 파라미터를 포함한 콘텐츠 목록 조회 시 200 응답을 반환한다") void getContents_withAllFilters_success() throws Exception { - // Given + // given CursorResponse response = new CursorResponse<>( List.of(), null, null, false, 0L, "createdAt", "DESCENDING" ); @@ -634,7 +634,7 @@ void getContents_withAllFilters_success() throws Exception { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ContentCursorRequest.class); - // When & Then + // when, then mockMvc.perform(get("/api/contents") .param("typeEqual", "movie") .param("keywordLike", "어벤져스") @@ -655,7 +655,7 @@ void getContents_withAllFilters_success() throws Exception { @Test @DisplayName("limit이 누락되면 400 응답을 반환한다") void getContents_missingLimit_returnsBadRequest() throws Exception { - // When & Then + // when, then mockMvc.perform(get("/api/contents") .param("sortDirection", "DESCENDING") .param("sortBy", "createdAt")) @@ -667,11 +667,11 @@ void getContents_missingLimit_returnsBadRequest() throws Exception { @Test @DisplayName("목록 조회 중 예상하지 못한 예외가 발생하면 500 응답을 반환한다") void getContents_unexpectedException_returnsInternalServerError() throws Exception { - // Given + // given given(contentService.findContents(any(ContentCursorRequest.class))) .willThrow(new IllegalStateException("unexpected")); - // When & Then + // when, then mockMvc.perform(get("/api/contents") .param("limit", "20") .param("sortDirection", "DESCENDING") @@ -685,11 +685,11 @@ void getContents_unexpectedException_returnsInternalServerError() throws Excepti @Test @DisplayName("정상적인 콘텐츠 삭제 요청이면 204 응답을 반환한다") void deleteContent_success() throws Exception { - // Given + // given UUID contentId = UUID.fromString("11111111-1111-1111-1111-111111111111"); doNothing().when(contentService).delete(contentId); - // When & Then + // when, then mockMvc.perform(delete("/api/contents/{contentId}", contentId)) .andExpect(status().isNoContent()); @@ -699,11 +699,11 @@ void deleteContent_success() throws Exception { @Test @DisplayName("존재하지 않는 콘텐츠 삭제 요청이면 404 응답을 반환한다") void deleteContent_notFound_returnsNotFound() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); doThrow(new ContentNotFoundException(contentId)).when(contentService).delete(contentId); - // When & Then + // when, then mockMvc.perform(delete("/api/contents/{contentId}", contentId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")) @@ -715,11 +715,11 @@ void deleteContent_notFound_returnsNotFound() throws Exception { @Test @DisplayName("삭제 중 예상하지 못한 예외가 발생하면 500 오류 응답을 반환한다") void deleteContent_unexpectedException_returnsInternalServerError() throws Exception { - // Given + // given UUID contentId = UUID.randomUUID(); doThrow(new IllegalStateException("unexpected")).when(contentService).delete(contentId); - // When & Then + // when, then mockMvc.perform(delete("/api/contents/{contentId}", contentId)) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.exceptionType").value("INTERNAL_SERVER_ERROR")) diff --git a/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidatorTest.java b/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidatorTest.java index 0d474966..ddb8516c 100644 --- a/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidatorTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidCursorValidatorTest.java @@ -31,6 +31,7 @@ private ContentCursorRequest request(String cursor, String idAfter, ContentSortB @Test @DisplayName("cursor와 idAfter 모두 null이면 유효하다") void valid_bothNull() { + // given, when, then assertThat(validator.isValid(request(null, null, ContentSortByType.CREATED_AT), context)).isTrue(); } @@ -38,12 +39,14 @@ void valid_bothNull() { @Test @DisplayName("cursor만 있고 idAfter가 null이면 유효하지 않다") void invalid_onlyCursor() { + // given, when, then assertThat(validator.isValid(request(Instant.now().toString(), null, ContentSortByType.CREATED_AT), context)).isFalse(); } @Test @DisplayName("idAfter만 있고 cursor가 null이면 유효하지 않다") void invalid_onlyIdAfter() { + // given, when, then assertThat(validator.isValid(request(null, UUID.randomUUID().toString(), ContentSortByType.CREATED_AT), context)).isFalse(); } @@ -51,6 +54,7 @@ void invalid_onlyIdAfter() { @Test @DisplayName("idAfter가 UUID 형식이 아니면 유효하지 않다") void invalid_idAfterNotUuid() { + // given, when, then assertThat(validator.isValid(request(Instant.now().toString(), "not-a-uuid", ContentSortByType.CREATED_AT), context)).isFalse(); } @@ -58,14 +62,18 @@ void invalid_idAfterNotUuid() { @Test @DisplayName("CREATED_AT 정렬에서 유효한 ISO-8601 cursor는 유효하다") void valid_createdAt_cursor() { + // given String cursor = Instant.now().toString(); String idAfter = UUID.randomUUID().toString(); + + // when, then assertThat(validator.isValid(request(cursor, idAfter, ContentSortByType.CREATED_AT), context)).isTrue(); } @Test @DisplayName("CREATED_AT 정렬에서 날짜 형식이 아닌 cursor는 유효하지 않다") void invalid_createdAt_cursor() { + // given, when, then assertThat(validator.isValid(request("not-a-date", UUID.randomUUID().toString(), ContentSortByType.CREATED_AT), context)).isFalse(); } @@ -73,12 +81,14 @@ void invalid_createdAt_cursor() { @Test @DisplayName("WATCHER_COUNT 정렬에서 유효한 숫자 cursor는 유효하다") void valid_watcherCount_cursor() { + // given, when, then assertThat(validator.isValid(request("100", UUID.randomUUID().toString(), ContentSortByType.WATCHER_COUNT), context)).isTrue(); } @Test @DisplayName("WATCHER_COUNT 정렬에서 숫자가 아닌 cursor는 유효하지 않다") void invalid_watcherCount_cursor() { + // given, when, then assertThat(validator.isValid(request("abc", UUID.randomUUID().toString(), ContentSortByType.WATCHER_COUNT), context)).isFalse(); } @@ -86,24 +96,28 @@ void invalid_watcherCount_cursor() { @Test @DisplayName("RATE 정렬에서 유효한 소수 cursor는 유효하다") void valid_rate_cursor() { + // given, when, then assertThat(validator.isValid(request("4.5", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isTrue(); } @Test @DisplayName("RATE 정렬에서 숫자가 아닌 cursor는 유효하지 않다") void invalid_rate_cursor_notNumber() { + // given, when, then assertThat(validator.isValid(request("abc", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isFalse(); } @Test @DisplayName("RATE 정렬에서 NaN cursor는 유효하지 않다") void invalid_rate_cursor_nan() { + // given, when, then assertThat(validator.isValid(request("NaN", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isFalse(); } @Test @DisplayName("RATE 정렬에서 Infinity cursor는 유효하지 않다") void invalid_rate_cursor_infinity() { + // given, when, then assertThat(validator.isValid(request("Infinity", UUID.randomUUID().toString(), ContentSortByType.RATE), context)).isFalse(); } } diff --git a/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidPageRangeValidatorTest.java b/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidPageRangeValidatorTest.java new file mode 100644 index 00000000..089d75ab --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/dto/request/ValidPageRangeValidatorTest.java @@ -0,0 +1,92 @@ +package com.codeit.team5.mopl.content.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import jakarta.validation.ConstraintValidatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ValidPageRangeValidatorTest { + + private ValidPageRangeValidator validator; + private ConstraintValidatorContext context; + + @BeforeEach + void setUp() { + validator = new ValidPageRangeValidator(); + context = mock(ConstraintValidatorContext.class, RETURNS_DEEP_STUBS); + } + + @Test + @DisplayName("startPage <= endPage면 유효하다") + void valid_endPageGreaterThanOrEqualToStartPage() { + // given + PageRangeRequest request = new PageRangeRequest(1, 5); + + // when, then + assertThat(validator.isValid(request, context)).isTrue(); + } + + @Test + @DisplayName("startPage == endPage면 유효하다") + void valid_startPageEqualsEndPage() { + // given + PageRangeRequest request = new PageRangeRequest(3, 3); + + // when, then + assertThat(validator.isValid(request, context)).isTrue(); + } + + @Test + @DisplayName("endPage가 startPage보다 작으면 유효하지 않고, 위반이 endPage 필드에 귀속된다") + void invalid_endPageLessThanStartPage() { + // given + PageRangeRequest request = new PageRangeRequest(5, 3); + + // when + boolean result = validator.isValid(request, context); + + // then + assertThat(result).isFalse(); + verify(context).disableDefaultConstraintViolation(); + verify(context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())) + .addPropertyNode("endPage"); + } + + @Test + @DisplayName("startPage가 0 이하여도 이 검증기는 관여하지 않는다 (@Min(1)의 책임)") + void valid_startPageBelowOne_notCheckedHere() { + // given + // startPage < 1은 @Min(1)이 검사할 몫이므로, 이 검증기는 endPage >= startPage만 보고 유효로 판단한다. + PageRangeRequest request = new PageRangeRequest(0, 1); + + // when + boolean result = validator.isValid(request, context); + + // then + assertThat(result).isTrue(); + verify(context, never()).disableDefaultConstraintViolation(); + } + + @Test + @DisplayName("startPage 또는 endPage가 null이면 유효하다 (@NotNull의 책임)") + void valid_nullFields_notCheckedHere() { + // given + PageRangeRequest request = new PageRangeRequest(null, null); + + // when, then + assertThat(validator.isValid(request, context)).isTrue(); + } + + @Test + @DisplayName("request가 null이면 유효하다") + void valid_nullRequest() { + // given, when, then + assertThat(validator.isValid(null, context)).isTrue(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/service/SportsDbContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/SportsDbContentServiceTest.java deleted file mode 100644 index 1f69c79b..00000000 --- a/src/test/java/com/codeit/team5/mopl/content/service/SportsDbContentServiceTest.java +++ /dev/null @@ -1,295 +0,0 @@ -package com.codeit.team5.mopl.content.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; -import com.codeit.team5.mopl.content.client.sportsdb.SportsDbApiClient; -import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto; -import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventListResponse; -import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.content.entity.ContentSource; -import com.codeit.team5.mopl.content.entity.ContentStats; -import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; -import com.codeit.team5.mopl.content.repository.ContentStatsRepository; -import com.codeit.team5.mopl.tag.entity.Tag; -import com.codeit.team5.mopl.tag.repository.TagRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.transaction.support.TransactionCallback; -import org.springframework.transaction.support.TransactionTemplate; - -@ExtendWith(MockitoExtension.class) -class SportsDbContentServiceTest { - - @Mock - private SportsDbApiClient sportsDbApiClient; - @Mock - private ContentRepository contentRepository; - @Mock - private ContentStatsRepository contentStatsRepository; - @Mock - private BinaryContentRepository binaryContentRepository; - @Mock - private TagRepository tagRepository; - @Mock - private TransactionTemplate transactionTemplate; - - @Spy - private ObjectMapper objectMapper = new ObjectMapper(); - - @InjectMocks - private SportsDbContentService sportsDbContentService; - - @SuppressWarnings("unchecked") - private void givenTransactionExecutesCallback() { - given(transactionTemplate.execute(any())).willAnswer(invocation -> { - TransactionCallback callback = invocation.getArgument(0); - return callback.doInTransaction(null); - }); - } - - private SportsDbEventDto eventDto(String idEvent, String strEvent, String strLeague, - String thumbUrl, String date, - String homeScore, String awayScore) { - return new SportsDbEventDto(idEvent, strEvent, thumbUrl, date, - strLeague, "Soccer", "Home FC", "Away FC", homeScore, awayScore); - } - - // --- collectEvents --- - - @Test - @DisplayName("정상적인 경기 이벤트는 저장된다") - void collectEvents_savesEvent() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("1001", "Home FC vs Away FC", "English Premier League", - "https://thumb.url/img.jpg", "2023-08-12", "2", "1"); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "1001")).willReturn(false); - - Content saved = mockContent(); - given(contentRepository.save(any())).willReturn(saved); - given(binaryContentRepository.save(any())).willReturn(mockBinaryContent()); - given(tagRepository.findByName("english premier league")).willReturn(Optional.empty()); - given(tagRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(contentRepository).save(any()); - verify(contentStatsRepository).save(any()); - } - - @Test - @DisplayName("API 응답 자체가 null이면 저장을 건너뛴다") - void collectEvents_nullResponse_skipsAll() { - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("API 응답의 events가 null이면 저장을 건너뛴다") - void collectEvents_nullEvents_skipsAll() { - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(null)); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("API 응답의 events가 빈 배열이면 저장을 건너뛴다") - void collectEvents_emptyEvents_skipsAll() { - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of())); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("이미 존재하는 경기는 저장을 건너뛴다") - void collectEvents_skipsAlreadyExistingEvent() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("2001", "Match", "EPL", null, "2023-09-01", null, null); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "2001")).willReturn(true); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("썸네일 URL이 없으면 BinaryContent를 저장하지 않는다") - void collectEvents_noThumbUrl_doesNotSaveThumbnail() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("3001", "Match", "English Premier League", null, "2023-09-01", null, null); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "3001")).willReturn(false); - given(contentRepository.save(any())).willReturn(mockContent()); - given(tagRepository.findByName(any())).willReturn(Optional.empty()); - given(tagRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(binaryContentRepository, never()).save(any()); - } - - @Test - @DisplayName("리그명이 소문자로 정규화되어 태그로 저장된다") - void collectEvents_normalizedLeagueTagSaved() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("4001", "Match", "English Premier League", - null, "2023-09-01", null, null); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "4001")).willReturn(false); - given(contentRepository.save(any())).willReturn(mockContent()); - given(tagRepository.findByName("english premier league")).willReturn(Optional.empty()); - - ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(Tag.class); - given(tagRepository.save(tagCaptor.capture())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - assertThat(tagCaptor.getValue().getName()).isEqualTo("english premier league"); - } - - @Test - @DisplayName("이미 존재하는 리그 태그는 새로 저장하지 않는다") - void collectEvents_reusesExistingLeagueTag() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("5001", "Match", "English Premier League", - null, "2023-09-01", null, null); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "5001")).willReturn(false); - given(contentRepository.save(any())).willReturn(mockContent()); - - Tag existingTag = Tag.create("english premier league"); - given(tagRepository.findByName("english premier league")).willReturn(Optional.of(existingTag)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(tagRepository, never()).save(any()); - } - - @Test - @DisplayName("스코어가 있으면 설명에 점수가 포함된다") - void collectEvents_withScore_includesScoreInDescription() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("6001", "Home FC vs Away FC", "EPL", - null, "2023-09-01", "3", "0"); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "6001")).willReturn(false); - - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Content.class); - given(contentRepository.save(contentCaptor.capture())).willReturn(mockContent()); - given(tagRepository.findByName(any())).willReturn(Optional.empty()); - given(tagRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - assertThat(contentCaptor.getValue().getDescription()).contains("3 - 0"); - } - - @Test - @DisplayName("스코어가 없으면 설명에 vs가 포함된다") - void collectEvents_withoutScore_includesVsInDescription() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("7001", "Home FC vs Away FC", "EPL", - null, "2023-09-01", null, null); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "7001")).willReturn(false); - - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Content.class); - given(contentRepository.save(contentCaptor.capture())).willReturn(mockContent()); - given(tagRepository.findByName(any())).willReturn(Optional.empty()); - given(tagRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - assertThat(contentCaptor.getValue().getDescription()).contains("vs"); - } - - @Test - @DisplayName("날짜 형식이 잘못된 경우 null로 저장된다") - void collectEvents_invalidDate_savesWithNullDate() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto = eventDto("8001", "Match", "EPL", null, "not-a-date", null, null); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "8001")).willReturn(false); - - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Content.class); - given(contentRepository.save(contentCaptor.capture())).willReturn(mockContent()); - given(tagRepository.findByName(any())).willReturn(Optional.empty()); - given(tagRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - assertThat(contentCaptor.getValue().getReleasedAt()).isNull(); - } - - @Test - @DisplayName("여러 경기가 있으면 각각 저장 여부를 확인하고 처리한다") - void collectEvents_multipleEvents_processesAll() { - givenTransactionExecutesCallback(); - SportsDbEventDto dto1 = eventDto("9001", "Match1", "EPL", null, "2023-09-01", null, null); - SportsDbEventDto dto2 = eventDto("9002", "Match2", "EPL", null, "2023-09-08", null, null); - given(sportsDbApiClient.fetchEventsBySeason("4328", "2023-2024")) - .willReturn(new SportsDbEventListResponse(List.of(dto1, dto2))); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "9001")).willReturn(false); - given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "9002")).willReturn(true); - given(contentRepository.save(any())).willReturn(mockContent()); - given(tagRepository.findByName(any())).willReturn(Optional.empty()); - given(tagRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - sportsDbContentService.collectEvents("4328", "2023-2024"); - - verify(contentRepository, org.mockito.Mockito.times(1)).save(any()); - } - - private Content mockContent() { - return Content.createByExternalSource( - ContentType.SPORT, "Home FC vs Away FC", "EPL Home FC 2 - 1 Away FC", - ContentSource.SPORTS_DB, "1001", null, "{}" - ); - } - - private BinaryContent mockBinaryContent() { - return BinaryContent.externalUrl("https://thumb.url/img.jpg"); - } -} diff --git a/src/test/java/com/codeit/team5/mopl/content/service/TmdbContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/TmdbContentServiceTest.java deleted file mode 100644 index ee54a292..00000000 --- a/src/test/java/com/codeit/team5/mopl/content/service/TmdbContentServiceTest.java +++ /dev/null @@ -1,337 +0,0 @@ -package com.codeit.team5.mopl.content.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; -import com.codeit.team5.mopl.content.client.tmdb.TmdbApiClient; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieDto; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbMovieListResponse; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvDto; -import com.codeit.team5.mopl.content.dto.external.tmdb.TmdbTvListResponse; -import com.codeit.team5.mopl.content.entity.Content; -import com.codeit.team5.mopl.content.entity.ContentSource; -import com.codeit.team5.mopl.content.entity.ContentStats; -import com.codeit.team5.mopl.content.entity.ContentType; -import com.codeit.team5.mopl.content.repository.ContentRepository; -import com.codeit.team5.mopl.content.repository.ContentStatsRepository; -import com.codeit.team5.mopl.tag.entity.Tag; -import com.codeit.team5.mopl.tag.repository.TagRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.transaction.support.TransactionCallback; -import org.springframework.transaction.support.TransactionTemplate; - -@ExtendWith(MockitoExtension.class) -class TmdbContentServiceTest { - - @Mock - private TmdbApiClient tmdbApiClient; - @Mock - private ContentRepository contentRepository; - @Mock - private ContentStatsRepository contentStatsRepository; - @Mock - private BinaryContentRepository binaryContentRepository; - @Mock - private TagRepository tagRepository; - @Mock - private TransactionTemplate transactionTemplate; - - @Spy - private ObjectMapper objectMapper = new ObjectMapper(); - - @InjectMocks - private TmdbContentService tmdbContentService; - - // TransactionTemplate.execute()가 콜백을 즉시 실행하도록 설정 - @SuppressWarnings("unchecked") - private void givenTransactionExecutesCallback() { - given(transactionTemplate.execute(any())).willAnswer(invocation -> { - TransactionCallback callback = invocation.getArgument(0); - return callback.doInTransaction(null); - }); - } - - private TmdbMovieDto movieDto(long id, String title, String originalTitle, List genreIds) { - return new TmdbMovieDto(id, title, originalTitle, "설명", "/poster.jpg", genreIds, "2023-01-01", 7.5, "en"); - } - - private TmdbTvDto tvDto(long id, String name, String originalName, List genreIds) { - return new TmdbTvDto(id, name, originalName, "설명", "/poster.jpg", genreIds, "2023-01-01", 7.5, "en"); - } - - // --- collectMovies --- - - @Test - @DisplayName("정상적인 영화는 저장된다") - void collectMovies_savesMovieWithKoreanTitle() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = movieDto(1L, "어벤져스", "Avengers", List.of(28L)); - given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "1")).willReturn(false); - - Content saved = mockContent(); - given(contentRepository.save(any())).willReturn(saved); - given(binaryContentRepository.save(any())).willReturn(mockBinaryContent()); - given(tagRepository.findByNameIn(List.of("액션"))).willReturn(List.of()); - given(tagRepository.saveAll(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - tmdbContentService.collectMovies(1, 1); - - verify(contentRepository).save(any()); - verify(contentStatsRepository).save(any()); - } - - @Test - @DisplayName("이미 존재하는 영화는 저장을 건너뛴다") - void collectMovies_skipsAlreadyExistingMovie() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = movieDto(3L, "어벤져스", "Avengers", List.of(28L)); - given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "3")).willReturn(true); - - tmdbContentService.collectMovies(1, 1); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("endPage가 500을 초과하면 500으로 클램핑하여 수집한다") - void collectMovies_clampsEndPageTo500() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = movieDto(4L, "어벤져스", "Avengers", List.of()); - TmdbMovieListResponse response = new TmdbMovieListResponse(1, List.of(dto), 500, 10000); - - // 500번 호출되면 실제로 sleep이 걸리므로 startPage=500, endPage=600으로 테스트 - given(tmdbApiClient.fetchMovies(500)).willReturn(response); - given(contentRepository.existsBySourceAndExternalId(any(), any())).willReturn(false); - given(contentRepository.save(any())).willReturn(mockContent()); - given(binaryContentRepository.save(any())).willReturn(mockBinaryContent()); - given(contentStatsRepository.save(any())).willReturn(null); - - tmdbContentService.collectMovies(500, 600); - - verify(tmdbApiClient, times(1)).fetchMovies(500); - verify(tmdbApiClient, never()).fetchMovies(501); - } - - @Test - @DisplayName("여러 페이지를 수집하면 각 페이지마다 API를 호출한다") - void collectMovies_multiplePages_callsApiForEachPage() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = movieDto(5L, "어벤져스", "Avengers", List.of()); - TmdbMovieListResponse response = new TmdbMovieListResponse(1, List.of(dto), 3, 3); - - given(tmdbApiClient.fetchMovies(any(Integer.class))).willReturn(response); - given(contentRepository.existsBySourceAndExternalId(any(), any())).willReturn(true); - - tmdbContentService.collectMovies(1, 3); - - verify(tmdbApiClient).fetchMovies(1); - verify(tmdbApiClient).fetchMovies(2); - verify(tmdbApiClient).fetchMovies(3); - } - - @Test - @DisplayName("totalPages가 endPage보다 작으면 totalPages에서 조기 종료한다") - void collectMovies_stopsAtTotalPages_whenLessThanEndPage() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = movieDto(7L, "어벤져스", "Avengers", List.of()); - TmdbMovieListResponse response = new TmdbMovieListResponse(1, List.of(dto), 2, 2); - - given(tmdbApiClient.fetchMovies(any(Integer.class))).willReturn(response); - given(contentRepository.existsBySourceAndExternalId(any(), any())).willReturn(true); - - tmdbContentService.collectMovies(1, 10); - - verify(tmdbApiClient).fetchMovies(1); - verify(tmdbApiClient).fetchMovies(2); - verify(tmdbApiClient, never()).fetchMovies(3); - } - - @Test - @DisplayName("title이 null이면 저장을 건너뛴다 (DB NOT NULL 제약 보호)") - void collectMovies_nullTitle_skips() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = new TmdbMovieDto(30L, null, "Avengers", "설명", null, List.of(), "2023-01-01", 7.5, "en"); - given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(dto), 1, 1)); - - tmdbContentService.collectMovies(1, 1); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("title이 공백이면 저장을 건너뛴다 (DB NOT NULL 제약 보호)") - void collectMovies_blankTitle_skips() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = new TmdbMovieDto(32L, " ", "Avengers", "설명", null, List.of(), "2023-01-01", 7.5, "en"); - given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(dto), 1, 1)); - - tmdbContentService.collectMovies(1, 1); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("posterPath가 없으면 썸네일을 저장하지 않는다") - void collectMovies_noPosterPath_doesNotSaveThumbnail() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = new TmdbMovieDto(6L, "어벤져스", "Avengers", "설명", null, List.of(), "2023-01-01", 7.5, "en"); - given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "6")).willReturn(false); - given(contentRepository.save(any())).willReturn(mockContent()); - given(contentStatsRepository.save(any())).willReturn(null); - - tmdbContentService.collectMovies(1, 1); - - verify(binaryContentRepository, never()).save(any()); - } - - // --- collectTvSeries --- - - @Test - @DisplayName("정상적인 TV 시리즈는 저장된다") - void collectTvSeries_savesTvWithKoreanName() { - givenTransactionExecutesCallback(); - TmdbTvDto dto = tvDto(10L, "더 글로리", "The Glory", List.of(18L)); - given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "10")).willReturn(false); - given(contentRepository.save(any())).willReturn(mockContent()); - given(binaryContentRepository.save(any())).willReturn(mockBinaryContent()); - given(tagRepository.findByNameIn(List.of("드라마"))).willReturn(List.of()); - given(tagRepository.saveAll(any())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - tmdbContentService.collectTvSeries(1, 1); - - verify(contentRepository).save(any()); - verify(contentStatsRepository).save(any()); - } - - @Test - @DisplayName("name이 null이면 저장을 건너뛴다 (DB NOT NULL 제약 보호)") - void collectTvSeries_nullName_skips() { - givenTransactionExecutesCallback(); - TmdbTvDto dto = new TmdbTvDto(31L, null, "The Glory", "설명", null, List.of(), "2023-01-01", 7.5, "en"); - given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(dto), 1, 1)); - - tmdbContentService.collectTvSeries(1, 1); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("name이 공백이면 저장을 건너뛴다 (DB NOT NULL 제약 보호)") - void collectTvSeries_blankName_skips() { - givenTransactionExecutesCallback(); - TmdbTvDto dto = new TmdbTvDto(33L, " ", "The Glory", "설명", null, List.of(), "2023-01-01", 7.5, "en"); - given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(dto), 1, 1)); - - tmdbContentService.collectTvSeries(1, 1); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("이미 존재하는 TV 시리즈는 저장을 건너뛴다") - void collectTvSeries_skipsAlreadyExisting() { - givenTransactionExecutesCallback(); - TmdbTvDto dto = tvDto(12L, "더 글로리", "The Glory", List.of()); - given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "12")).willReturn(true); - - tmdbContentService.collectTvSeries(1, 1); - - verify(contentRepository, never()).save(any()); - } - - @Test - @DisplayName("장르 ID가 매핑되면 태그로 저장되고, 알 수 없는 장르 ID는 무시된다") - void collectTvSeries_attachesKnownGenreTags() { - givenTransactionExecutesCallback(); - TmdbTvDto dto = tvDto(13L, "더 글로리", "The Glory", List.of(18L, 99999L)); - given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "13")).willReturn(false); - - Content saved = mockContent(); - given(contentRepository.save(any())).willReturn(saved); - given(binaryContentRepository.save(any())).willReturn(mockBinaryContent()); - given(tagRepository.findByNameIn(List.of("드라마"))).willReturn(List.of()); // 원본 라벨로 조회 - ArgumentCaptor tagListCaptor = ArgumentCaptor.forClass(List.class); - given(tagRepository.saveAll(tagListCaptor.capture())).willAnswer(inv -> inv.getArgument(0)); - given(contentStatsRepository.save(any())).willReturn(null); - - tmdbContentService.collectTvSeries(1, 1); - - List savedTags = tagListCaptor.getValue(); - assertThat(savedTags).hasSize(1); - assertThat(((Tag) savedTags.get(0)).getName()).isEqualTo("드라마"); - } - - @Test - @DisplayName("이미 존재하는 태그는 새로 저장하지 않는다") - void collectTvSeries_reusesExistingTag() { - givenTransactionExecutesCallback(); - TmdbTvDto dto = tvDto(14L, "더 글로리", "The Glory", List.of(18L)); - given(tmdbApiClient.fetchTvSeries(1)).willReturn(new TmdbTvListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "14")).willReturn(false); - - Content saved = mockContent(); - given(contentRepository.save(any())).willReturn(saved); - given(binaryContentRepository.save(any())).willReturn(mockBinaryContent()); - - Tag existingTag = Tag.create("드라마"); - given(tagRepository.findByNameIn(List.of("드라마"))).willReturn(List.of(existingTag)); - given(contentStatsRepository.save(any())).willReturn(null); - - tmdbContentService.collectTvSeries(1, 1); - - verify(tagRepository, never()).saveAll(any()); - } - - @Test - @DisplayName("날짜 형식이 잘못된 경우 null로 저장된다") - void collectMovies_invalidDate_savesWithNullDate() { - givenTransactionExecutesCallback(); - TmdbMovieDto dto = new TmdbMovieDto(20L, "어벤져스", "Avengers", "설명", null, List.of(), "invalid-date", 7.5, "en"); - given(tmdbApiClient.fetchMovies(1)).willReturn(new TmdbMovieListResponse(1, List.of(dto), 1, 1)); - given(contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, "20")).willReturn(false); - - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(Content.class); - given(contentRepository.save(contentCaptor.capture())).willReturn(mockContent()); - given(contentStatsRepository.save(any())).willReturn(null); - - tmdbContentService.collectMovies(1, 1); - - assertThat(contentCaptor.getValue().getReleasedAt()).isNull(); - } - - private Content mockContent() { - return Content.createByExternalSource( - ContentType.MOVIE, "어벤져스", "설명", - ContentSource.TMDB, "1", null, "{}" - ); - } - - private BinaryContent mockBinaryContent() { - return BinaryContent.externalUrl("https://image.tmdb.org/t/p/w500/poster.jpg"); - } -} diff --git a/src/test/java/com/codeit/team5/mopl/content/service/util/ContentCollectionUtilsTest.java b/src/test/java/com/codeit/team5/mopl/content/service/util/ContentCollectionUtilsTest.java index 4e939f33..3df02642 100644 --- a/src/test/java/com/codeit/team5/mopl/content/service/util/ContentCollectionUtilsTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/service/util/ContentCollectionUtilsTest.java @@ -33,8 +33,10 @@ class ContentCollectionUtilsTest { @Test @DisplayName("정상적인 날짜 문자열은 UTC Instant로 변환된다") void parseDate_validDate_returnsInstant() { + // when Instant result = ContentCollectionUtils.parseDate("2023-08-12", "TEST"); + // then Instant expected = LocalDate.of(2023, 8, 12).atStartOfDay(ZoneOffset.UTC).toInstant(); assertThat(result).isEqualTo(expected); } @@ -42,18 +44,21 @@ void parseDate_validDate_returnsInstant() { @Test @DisplayName("날짜가 null이면 null을 반환한다") void parseDate_nullDate_returnsNull() { + // when, then assertThat(ContentCollectionUtils.parseDate(null, "TEST")).isNull(); } @Test @DisplayName("날짜가 빈 문자열이면 null을 반환한다") void parseDate_blankDate_returnsNull() { + // when, then assertThat(ContentCollectionUtils.parseDate(" ", "TEST")).isNull(); } @Test @DisplayName("날짜 형식이 잘못된 경우 null을 반환한다") void parseDate_invalidFormat_returnsNull() { + // when, then assertThat(ContentCollectionUtils.parseDate("not-a-date", "TEST")).isNull(); } @@ -62,13 +67,16 @@ void parseDate_invalidFormat_returnsNull() { @Test @DisplayName("URL이 있으면 baseUrl을 붙여 BinaryContent를 저장하고 콘텐츠에 첨부한다") void attachThumbnail_validUrl_savesAndAttaches() { + // given Content content = mockContent(); BinaryContent thumbnail = BinaryContent.externalUrl("https://base.url/poster.jpg"); ArgumentCaptor captor = ArgumentCaptor.forClass(BinaryContent.class); given(binaryContentRepository.save(captor.capture())).willReturn(thumbnail); + // when ContentCollectionUtils.attachThumbnail(content, "/poster.jpg", binaryContentRepository, "https://base.url"); + // then assertThat(captor.getValue().getUrl()).isEqualTo("https://base.url/poster.jpg"); assertThat(content.getThumbnail()).isEqualTo(thumbnail); } @@ -76,33 +84,42 @@ void attachThumbnail_validUrl_savesAndAttaches() { @Test @DisplayName("baseUrl이 빈 문자열이면 URL을 그대로 사용한다") void attachThumbnail_emptyBaseUrl_usesUrlAsIs() { + // given Content content = mockContent(); BinaryContent thumbnail = BinaryContent.externalUrl("https://thumb.url/img.jpg"); ArgumentCaptor captor = ArgumentCaptor.forClass(BinaryContent.class); given(binaryContentRepository.save(captor.capture())).willReturn(thumbnail); + // when ContentCollectionUtils.attachThumbnail(content, "https://thumb.url/img.jpg", binaryContentRepository, ""); + // then assertThat(captor.getValue().getUrl()).isEqualTo("https://thumb.url/img.jpg"); } @Test @DisplayName("URL이 null이면 저장하지 않는다") void attachThumbnail_nullUrl_doesNotSave() { + // given Content content = mockContent(); + // when ContentCollectionUtils.attachThumbnail(content, null, binaryContentRepository, "https://base.url"); + // then verify(binaryContentRepository, never()).save(any()); } @Test @DisplayName("URL이 빈 문자열이면 저장하지 않는다") void attachThumbnail_blankUrl_doesNotSave() { + // given Content content = mockContent(); + // when ContentCollectionUtils.attachThumbnail(content, " ", binaryContentRepository, "https://base.url"); + // then verify(binaryContentRepository, never()).save(any()); } diff --git a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java index 646b0bd2..cc53a165 100644 --- a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java @@ -228,7 +228,7 @@ void getUser_invalidUuid() throws Exception { // When & Then mockMvc.perform(get("/api/users/{userId}", "invalid-uuid") .with(authentication(authOf(UUID.randomUUID())))) - .andExpect(status().isInternalServerError()); + .andExpect(status().isBadRequest()); } @Test