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 extends ContentWithMetaData> 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 extends Payload>[] 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에 저장하는 서비스.
- *
- * 수집 기준:
- *
- * 리그 ID와 시즌을 조합하여 한 시즌의 전체 경기를 일괄 수집
- * 중복 저장 방지: idEvent 기준 존재 여부 확인
- * 리그명을 정규화(소문자 trim)하여 태그로 부착
- *
- *
- */
-@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에 저장하는 서비스.
- *
- * 수집 기준:
- *
- * 한국어 번역 제목이 존재하는 콘텐츠만 저장 (title ≠ originalTitle 조건)
- * 성인 콘텐츠 제외: include_adult=false, 영화 certification ≤ R, TV certification ≤ TV-14
- * 중복 저장 방지: externalId 기준 존재 여부 확인
- * 페이지 단위 트랜잭션 적용, 요청 사이 {@value #REQUEST_DELAY_MS}ms 대기
- *
- *
- */
-@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