diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerIntegrationTest.java
new file mode 100644
index 00000000..2f998250
--- /dev/null
+++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerIntegrationTest.java
@@ -0,0 +1,395 @@
+package com.codeit.team5.mopl.content.controller;
+
+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.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+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.TestcontainersConfiguration;
+import com.codeit.team5.mopl.global.support.security.IntegrationTestSecuritySupport;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.batch.core.JobParameters;
+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.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.security.core.Authentication;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * ContentCollectionController 통합 테스트.
+ *
+ *
실제 시큐리티 필터체인, 요청 검증·타입 변환(컨버터), 컨트롤러 로직을 모두 주입하여 수집
+ * 엔드포인트의 인증/인가(401/403), 요청 검증(400), Job 실행 경로(202/500)를 한곳에서 검증한다.
+ *
+ * 실제 배치 Job은 외부 API(TMDB/SportsDB)를 호출하므로, JobLauncher와 Job 빈만 목으로 대체하여
+ * "요청이 정상적으로 Job 실행 경로까지 도달하는지(202)"와 "실행 실패 시 500"만 검증한다.
+ *
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Import(TestcontainersConfiguration.class)
+class ContentCollectionControllerIntegrationTest {
+
+ private static final String MOVIES_URL = "/api/admin/contents/collect/tmdb/movies";
+ private static final String TV_URL = "/api/admin/contents/collect/tmdb/tv";
+ private static final String SPORTS_URL = "/api/admin/contents/collect/sports";
+ private static final String SPORTS_DAY_URL = "/api/admin/contents/collect/sports/day";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ // 실제 Job 빈은 그대로 두고(배치 JobRegistry가 getName()으로 등록하므로 목으로 대체하면 NPE 발생),
+ // JobLauncher만 목으로 대체해 실제 배치 실행(외부 API 호출)을 차단한다.
+ @MockitoBean(name = "asyncJobLauncher")
+ private JobLauncher asyncJobLauncher;
+
+ // --- 인증 헬퍼 ---
+
+ private Authentication adminAuth() {
+ return IntegrationTestSecuritySupport.adminAuthentication();
+ }
+
+ private Authentication userAuth() {
+ return IntegrationTestSecuritySupport.userAuthentication();
+ }
+
+ @Nested
+ @DisplayName("POST /tmdb/movies - TMDB 영화 수집")
+ class CollectTmdbMovies {
+
+ @Test
+ @DisplayName("ADMIN이 유효한 페이지 범위로 요청하면 202를 반환하고 JobParameters에 startPage/endPage가 실제로 담겨 전달된다")
+ void success_returnsAccepted() throws Exception {
+ // given
+ ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(JobParameters.class);
+
+ // when & then
+ mockMvc.perform(post(MOVIES_URL)
+ .param("startPage", "1")
+ .param("endPage", "5")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isAccepted());
+
+ verify(asyncJobLauncher).run(any(), paramsCaptor.capture());
+ JobParameters params = paramsCaptor.getValue();
+ assertThat(params.getString("startPage")).isEqualTo("1");
+ assertThat(params.getString("endPage")).isEqualTo("5");
+ }
+
+ @Test
+ @DisplayName("USER 권한이면 403을 반환한다")
+ void asUser_returnsForbidden() throws Exception {
+ // when & then
+ mockMvc.perform(post(MOVIES_URL)
+ .param("startPage", "1")
+ .param("endPage", "5")
+ .with(csrf())
+ .with(authentication(userAuth())))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @DisplayName("인증 없이 요청하면 401을 반환한다")
+ void unauthenticated_returnsUnauthorized() throws Exception {
+ // when & then
+ mockMvc.perform(post(MOVIES_URL)
+ .param("startPage", "1")
+ .param("endPage", "5")
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @DisplayName("페이지 파라미터가 누락되면 400을 반환한다")
+ void missingParams_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(MOVIES_URL)
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("startPage가 0 이하면 400을 반환한다")
+ void invalidStartPage_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(MOVIES_URL)
+ .param("startPage", "0")
+ .param("endPage", "5")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("endPage가 startPage보다 작으면 400을 반환한다")
+ void endPageLessThanStartPage_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(MOVIES_URL)
+ .param("startPage", "5")
+ .param("endPage", "3")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("Job 실행 자체가 실패하면 500을 반환한다")
+ void jobLaunchFails_returnsInternalServerError() throws Exception {
+ // given
+ given(asyncJobLauncher.run(any(), any()))
+ .willThrow(new JobParametersInvalidException("잘못된 Job 파라미터"));
+
+ // when & then
+ mockMvc.perform(post(MOVIES_URL)
+ .param("startPage", "1")
+ .param("endPage", "5")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isInternalServerError());
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /tmdb/tv - TMDB TV 시리즈 수집")
+ class CollectTmdbTvSeries {
+
+ @Test
+ @DisplayName("ADMIN이 유효한 페이지 범위로 요청하면 202를 반환하고 JobParameters에 startPage/endPage가 실제로 담겨 전달된다")
+ void success_returnsAccepted() throws Exception {
+ // given
+ ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(JobParameters.class);
+
+ // when & then
+ mockMvc.perform(post(TV_URL)
+ .param("startPage", "2")
+ .param("endPage", "10")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isAccepted());
+
+ verify(asyncJobLauncher).run(any(), paramsCaptor.capture());
+ JobParameters params = paramsCaptor.getValue();
+ assertThat(params.getString("startPage")).isEqualTo("2");
+ assertThat(params.getString("endPage")).isEqualTo("10");
+ }
+
+ @Test
+ @DisplayName("USER 권한이면 403을 반환한다")
+ void asUser_returnsForbidden() throws Exception {
+ // when & then
+ mockMvc.perform(post(TV_URL)
+ .param("startPage", "2")
+ .param("endPage", "10")
+ .with(csrf())
+ .with(authentication(userAuth())))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @DisplayName("인증 없이 요청하면 401을 반환한다")
+ void unauthenticated_returnsUnauthorized() throws Exception {
+ // when & then
+ mockMvc.perform(post(TV_URL)
+ .param("startPage", "2")
+ .param("endPage", "10")
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @DisplayName("페이지 파라미터가 누락되면 400을 반환한다")
+ void missingParams_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(TV_URL)
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("endPage가 startPage보다 작으면 400을 반환한다")
+ void endPageLessThanStartPage_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(TV_URL)
+ .param("startPage", "10")
+ .param("endPage", "5")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /sports - SportsDB 경기 수집")
+ class CollectSportsEvents {
+
+ @Test
+ @DisplayName("ADMIN이 유효한 리그·시즌으로 요청하면 202를 반환하고 JobParameters에 leagueId/season이 실제로 담겨 전달된다")
+ void success_returnsAccepted() throws Exception {
+ // given
+ ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(JobParameters.class);
+
+ // when & then
+ mockMvc.perform(post(SPORTS_URL)
+ .param("league", "EPL")
+ .param("season", "2023-2024")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isAccepted());
+
+ verify(asyncJobLauncher).run(any(), paramsCaptor.capture());
+ JobParameters params = paramsCaptor.getValue();
+ assertThat(params.getString("leagueId")).isEqualTo("4328");
+ assertThat(params.getString("season")).isEqualTo("2023-2024");
+ }
+
+ @Test
+ @DisplayName("USER 권한이면 403을 반환한다")
+ void asUser_returnsForbidden() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_URL)
+ .param("league", "EPL")
+ .param("season", "2023-2024")
+ .with(csrf())
+ .with(authentication(userAuth())))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @DisplayName("인증 없이 요청하면 401을 반환한다")
+ void unauthenticated_returnsUnauthorized() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_URL)
+ .param("league", "EPL")
+ .param("season", "2023-2024")
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @DisplayName("league 파라미터가 누락되면 400을 반환한다")
+ void missingLeague_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_URL)
+ .param("season", "2023-2024")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("유효하지 않은 리그 값이면 400을 반환한다")
+ void invalidLeague_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_URL)
+ .param("league", "INVALID_LEAGUE")
+ .param("season", "2023-2024")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("시즌 형식이 YYYY-YYYY가 아니면 400을 반환한다")
+ void invalidSeasonFormat_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_URL)
+ .param("league", "EPL")
+ .param("season", "2023/2024")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+ }
+
+ @Nested
+ @DisplayName("POST /sports/day - SportsDB 일별 경기 수집")
+ class CollectSportsEventsByDay {
+
+ @Test
+ @DisplayName("ADMIN이 유효한 날짜로 요청하면 202를 반환하고 JobParameters에 date가 실제로 담겨 전달된다")
+ void success_returnsAccepted() throws Exception {
+ // given
+ ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(JobParameters.class);
+
+ // when & then
+ mockMvc.perform(post(SPORTS_DAY_URL)
+ .param("date", "2024-12-26")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isAccepted());
+
+ verify(asyncJobLauncher).run(any(), paramsCaptor.capture());
+ JobParameters params = paramsCaptor.getValue();
+ assertThat(params.getString("date")).isEqualTo("2024-12-26");
+ }
+
+ @Test
+ @DisplayName("USER 권한이면 403을 반환한다")
+ void asUser_returnsForbidden() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_DAY_URL)
+ .param("date", "2024-12-26")
+ .with(csrf())
+ .with(authentication(userAuth())))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @DisplayName("인증 없이 요청하면 401을 반환한다")
+ void unauthenticated_returnsUnauthorized() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_DAY_URL)
+ .param("date", "2024-12-26")
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @DisplayName("date 파라미터가 누락되면 400을 반환한다")
+ void missingDate_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_DAY_URL)
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("날짜 형식이 YYYY-MM-DD가 아니면 400을 반환한다")
+ void invalidDateFormat_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_DAY_URL)
+ .param("date", "2024/12/26")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("실존하지 않는 날짜면 400을 반환한다")
+ void nonExistentDate_returnsBadRequest() throws Exception {
+ // when & then
+ mockMvc.perform(post(SPORTS_DAY_URL)
+ .param("date", "2024-02-31")
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest());
+ }
+ }
+}
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
deleted file mode 100644
index 0dc54e0f..00000000
--- a/src/test/java/com/codeit/team5/mopl/content/controller/ContentCollectionControllerTest.java
+++ /dev/null
@@ -1,224 +0,0 @@
-package com.codeit.team5.mopl.content.controller;
-
-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.jwt.JwtAuthenticationFilter;
-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;
-import org.springframework.context.annotation.ComponentScan;
-import org.springframework.context.annotation.FilterType;
-import org.springframework.context.annotation.Import;
-import org.springframework.test.context.bean.override.mockito.MockitoBean;
-import org.springframework.test.web.servlet.MockMvc;
-
-@WebMvcTest(
- controllers = ContentCollectionController.class,
- excludeFilters = @ComponentScan.Filter(
- type = FilterType.ASSIGNABLE_TYPE,
- classes = JwtAuthenticationFilter.class
- )
-)
-@AutoConfigureMockMvc(addFilters = false)
-@Import({GlobalExceptionHandler.class, TestGlobalExceptionHandlerConfig.class})
-class ContentCollectionControllerTest {
-
- @Autowired
- private MockMvc mockMvc;
-
- @MockitoBean(name = "asyncJobLauncher")
- private JobLauncher asyncJobLauncher;
-
- @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());
- }
-
- @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 영화 수집 파라미터 생략 시 400 응답을 반환한다")
- void collectTmdbMovies_missingParams_returnsBadRequest() throws Exception {
- // when, then
- mockMvc.perform(post("/api/admin/contents/collect/tmdb/movies"))
- .andExpect(status().isBadRequest());
- }
-
- @Test
- @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());
- }
-
- @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 시리즈 수집 ---
-
- @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());
- }
-
- @Test
- @DisplayName("TMDB TV 수집 파라미터 생략 시 400 응답을 반환한다")
- void collectTmdbTvSeries_missingParams_returnsBadRequest() throws Exception {
- // when, then
- mockMvc.perform(post("/api/admin/contents/collect/tmdb/tv"))
- .andExpect(status().isBadRequest());
- }
-
- @Test
- @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 경기 수집 ---
-
- @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());
- }
-
- @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 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().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/ContentControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerIntegrationTest.java
new file mode 100644
index 00000000..fae29b0c
--- /dev/null
+++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerIntegrationTest.java
@@ -0,0 +1,460 @@
+package com.codeit.team5.mopl.content.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.codeit.team5.mopl.TestcontainersConfiguration;
+import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
+import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
+import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
+import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
+import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
+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.entity.ContentType;
+import com.codeit.team5.mopl.content.repository.ContentRepository;
+import com.codeit.team5.mopl.content.repository.ContentStatsRepository;
+import com.codeit.team5.mopl.global.support.security.IntegrationTestSecuritySupport;
+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.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.core.Authentication;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * ContentController 통합 테스트.
+ *
+ * {@code @WebMvcTest} 기반 단위 테스트({@link ContentControllerTest})와 달리 실제 시큐리티 필터체인,
+ * ContentService, Repository, ContentMapper, 검증 로직을 모두 주입하여 Testcontainers DB에 실제로
+ * 반영되는지 검증한다.
+ *
+ * 썸네일 업로드(S3) 경로는 이 컨트롤러 통합 테스트에서 다루지 않는다.
+ * ({@code UserControllerIntegrationTest}와 동일한 컨벤션 — 이미지 업로드/스토리지 검증은 서비스 레벨
+ * 통합 테스트의 책임으로 분리한다.)
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Import(TestcontainersConfiguration.class)
+@Transactional
+class ContentControllerIntegrationTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private ContentRepository contentRepository;
+
+ @Autowired
+ private ContentStatsRepository contentStatsRepository;
+
+ @Autowired
+ private TagRepository tagRepository;
+
+ @Autowired
+ private BinaryContentRepository binaryContentRepository;
+
+ // --- 인증 헬퍼 ---
+
+ private Authentication adminAuth() {
+ return IntegrationTestSecuritySupport.adminAuthentication();
+ }
+
+ private Authentication userAuth() {
+ return IntegrationTestSecuritySupport.userAuthentication();
+ }
+
+ // --- 데이터 준비 헬퍼 ---
+
+ private Content persistContent(ContentType type, String title, String description, String... tagNames) {
+ Content content = contentRepository.save(Content.createByAdmin(type, title, description));
+ ContentStats stats = contentStatsRepository.save(ContentStats.create(content));
+ content.attachStats(stats);
+ for (String name : tagNames) {
+ // 여러 콘텐츠가 같은 태그를 공유할 수 있으므로 기존 태그를 재사용한다 (tags.name 유니크 제약)
+ Tag tag = tagRepository.findByName(name)
+ .orElseGet(() -> tagRepository.save(Tag.create(name)));
+ content.addTag(ContentTag.create(content, tag));
+ }
+ return contentRepository.saveAndFlush(content);
+ }
+
+ private Set tagNamesOf(Content content) {
+ return content.getContentTags().stream()
+ .map(ct -> ct.getTag().getName())
+ .collect(Collectors.toSet());
+ }
+
+ private MockMultipartFile requestPart(Object request) throws Exception {
+ return new MockMultipartFile(
+ "request", "", MediaType.APPLICATION_JSON_VALUE,
+ objectMapper.writeValueAsBytes(request));
+ }
+
+ @Nested
+ @DisplayName("POST /api/contents - 콘텐츠 생성")
+ class PostContent {
+
+ @Test
+ @DisplayName("ADMIN이 생성하면 201과 함께 DB에 콘텐츠·통계·태그가 저장되고 태그가 정규화된다")
+ void create_persists() throws Exception {
+ // given
+ ContentCreateRequest request = new ContentCreateRequest(
+ ContentType.MOVIE, "테스트 영화", "설명", List.of("액션", " 액션 ", "드라마"));
+
+ // when & then
+ String body = mockMvc.perform(multipart("/api/contents")
+ .file(requestPart(request))
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").exists())
+ .andExpect(jsonPath("$.type").value("movie"))
+ .andExpect(jsonPath("$.title").value("테스트 영화"))
+ .andExpect(jsonPath("$.description").value("설명"))
+ .andExpect(jsonPath("$.thumbnailUrl").doesNotExist())
+ .andExpect(jsonPath("$.averageRating").value(0.0))
+ .andExpect(jsonPath("$.reviewCount").value(0))
+ .andExpect(jsonPath("$.watcherCount").value(0))
+ .andReturn().getResponse().getContentAsString();
+
+ UUID contentId = UUID.fromString(objectMapper.readTree(body).get("id").asText());
+ Content saved = contentRepository.findWithStatsAndTagsById(contentId).orElseThrow();
+ assertThat(saved.getTitle()).isEqualTo("테스트 영화");
+ assertThat(saved.getType()).isEqualTo(ContentType.MOVIE);
+ assertThat(saved.getStats()).isNotNull();
+ // 태그는 trim·중복 제거되어 2개만 저장되어야 한다
+ assertThat(tagNamesOf(saved)).containsExactlyInAnyOrder("액션", "드라마");
+ }
+
+ @Test
+ @DisplayName("USER 권한이면 403과 함께 아무것도 저장되지 않는다")
+ void create_asUser_forbidden() throws Exception {
+ // given
+ ContentCreateRequest request = new ContentCreateRequest(
+ ContentType.MOVIE, "테스트 영화", null, List.of("액션"));
+
+ // when & then
+ mockMvc.perform(multipart("/api/contents")
+ .file(requestPart(request))
+ .with(csrf())
+ .with(authentication(userAuth())))
+ .andExpect(status().isForbidden());
+
+ assertThat(contentRepository.count()).isZero();
+ }
+
+ @Test
+ @DisplayName("인증 없이 생성하면 401을 반환한다")
+ void create_unauthenticated_unauthorized() throws Exception {
+ // given
+ ContentCreateRequest request = new ContentCreateRequest(
+ ContentType.MOVIE, "테스트 영화", null, List.of("액션"));
+
+ // when & then
+ mockMvc.perform(multipart("/api/contents")
+ .file(requestPart(request))
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+
+ assertThat(contentRepository.count()).isZero();
+ }
+
+ @Test
+ @DisplayName("제목이 공백이면 400 검증 실패를 반환한다")
+ void create_blankTitle_badRequest() throws Exception {
+ // given
+ ContentCreateRequest request = new ContentCreateRequest(
+ ContentType.MOVIE, " ", null, List.of("액션"));
+
+ // when & then
+ mockMvc.perform(multipart("/api/contents")
+ .file(requestPart(request))
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT"));
+
+ assertThat(contentRepository.count()).isZero();
+ }
+ }
+
+ @Nested
+ @DisplayName("PATCH /api/contents/{id} - 콘텐츠 수정")
+ class PatchContent {
+
+ @Test
+ @DisplayName("ADMIN이 수정하면 200과 함께 제목·설명·태그가 DB에 반영된다")
+ void update_persists() throws Exception {
+ // given
+ Content content = persistContent(ContentType.MOVIE, "원본 영화", "원본 설명", "액션", "드라마");
+ ContentUpdateRequest request = new ContentUpdateRequest(
+ "수정된 영화", "수정된 설명", List.of("액션", "sf"));
+
+ // when & then
+ mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{id}", content.getId())
+ .file(requestPart(request))
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.title").value("수정된 영화"))
+ .andExpect(jsonPath("$.description").value("수정된 설명"));
+
+ Content updated = contentRepository.findWithStatsAndTagsById(content.getId()).orElseThrow();
+ assertThat(updated.getTitle()).isEqualTo("수정된 영화");
+ assertThat(updated.getDescription()).isEqualTo("수정된 설명");
+ // "드라마"는 제거되고 "sf"가 추가되어야 한다 (diff 반영)
+ assertThat(tagNamesOf(updated)).containsExactlyInAnyOrder("액션", "sf");
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 콘텐츠 수정이면 404를 반환한다")
+ void update_notFound() throws Exception {
+ // given
+ ContentUpdateRequest request = new ContentUpdateRequest("수정", null, List.of("액션"));
+
+ // when & then
+ mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{id}", UUID.randomUUID())
+ .file(requestPart(request))
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException"));
+ }
+
+ @Test
+ @DisplayName("USER 권한이면 403과 함께 수정되지 않는다")
+ void update_asUser_forbidden() throws Exception {
+ // given
+ Content content = persistContent(ContentType.MOVIE, "원본 영화", null, "액션");
+ ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("sf"));
+
+ // when & then
+ mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{id}", content.getId())
+ .file(requestPart(request))
+ .with(csrf())
+ .with(authentication(userAuth())))
+ .andExpect(status().isForbidden());
+
+ Content unchanged = contentRepository.findWithStatsAndTagsById(content.getId()).orElseThrow();
+ assertThat(unchanged.getTitle()).isEqualTo("원본 영화");
+ }
+
+ @Test
+ @DisplayName("인증 없이 수정하면 401을 반환한다")
+ void update_unauthenticated_unauthorized() throws Exception {
+ // given
+ ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("액션"));
+
+ // when & then
+ mockMvc.perform(multipart(HttpMethod.PATCH, "/api/contents/{id}", UUID.randomUUID())
+ .file(requestPart(request))
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/contents/{id} - 단건 조회")
+ class GetContent {
+
+ @Test
+ @DisplayName("존재하는 콘텐츠를 인증 사용자가 조회하면 200과 함께 통계·태그를 반환한다")
+ void getById_success() throws Exception {
+ // given
+ Content content = persistContent(ContentType.MOVIE, "조회 영화", "설명", "액션", "sf");
+
+ // when & then
+ mockMvc.perform(get("/api/contents/{id}", content.getId())
+ .with(authentication(userAuth())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(content.getId().toString()))
+ .andExpect(jsonPath("$.title").value("조회 영화"))
+ .andExpect(jsonPath("$.tags", org.hamcrest.Matchers.containsInAnyOrder("액션", "sf")))
+ .andExpect(jsonPath("$.averageRating").value(0.0));
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 콘텐츠 조회면 404를 반환한다")
+ void getById_notFound() throws Exception {
+ // when & then
+ mockMvc.perform(get("/api/contents/{id}", UUID.randomUUID())
+ .with(authentication(userAuth())))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException"));
+ }
+
+ @Test
+ @DisplayName("인증 없이 단건 조회면 401을 반환한다")
+ void getById_unauthenticated() throws Exception {
+ // given
+ Content content = persistContent(ContentType.MOVIE, "조회 영화", null, "액션");
+
+ // when & then
+ mockMvc.perform(get("/api/contents/{id}", content.getId()))
+ .andExpect(status().isUnauthorized());
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/contents - 목록 조회")
+ class GetContents {
+
+ @Test
+ @DisplayName("타입·키워드 필터로 조회하면 조건에 맞는 콘텐츠만 반환한다")
+ void list_withFilters() throws Exception {
+ // given
+ persistContent(ContentType.MOVIE, "어벤져스", "히어로", "액션");
+ persistContent(ContentType.MOVIE, "인터스텔라", "우주", "sf");
+ persistContent(ContentType.TV_SERIES, "어벤져스 드라마", null, "액션");
+
+ // when & then
+ mockMvc.perform(get("/api/contents")
+ .param("typeEqual", "movie")
+ .param("keywordLike", "어벤져스")
+ .param("limit", "20")
+ .param("sortDirection", "DESCENDING")
+ .param("sortBy", "createdAt")
+ .with(authentication(userAuth())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.length()").value(1))
+ .andExpect(jsonPath("$.data[0].title").value("어벤져스"))
+ .andExpect(jsonPath("$.totalCount").value(1))
+ .andExpect(jsonPath("$.hasNext").value(false))
+ .andExpect(jsonPath("$.sortBy").value("createdAt"))
+ .andExpect(jsonPath("$.sortDirection").value("DESCENDING"));
+ }
+
+ @Test
+ @DisplayName("limit보다 데이터가 많으면 hasNext=true와 nextCursor를 반환한다")
+ void list_pagination_hasNext() throws Exception {
+ // given
+ for (int i = 0; i < 3; i++) {
+ persistContent(ContentType.MOVIE, "영화" + i, null, "액션");
+ }
+
+ // when & then
+ mockMvc.perform(get("/api/contents")
+ .param("limit", "2")
+ .param("sortDirection", "DESCENDING")
+ .param("sortBy", "createdAt")
+ .with(authentication(userAuth())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.length()").value(2))
+ .andExpect(jsonPath("$.hasNext").value(true))
+ .andExpect(jsonPath("$.nextCursor").exists())
+ .andExpect(jsonPath("$.totalCount").value(3));
+ }
+
+ @Test
+ @DisplayName("limit이 누락되면 400을 반환한다")
+ void list_missingLimit_badRequest() throws Exception {
+ // when & then
+ mockMvc.perform(get("/api/contents")
+ .param("sortDirection", "DESCENDING")
+ .param("sortBy", "createdAt")
+ .with(authentication(userAuth())))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ @DisplayName("인증 없이 목록 조회면 401을 반환한다")
+ void list_unauthenticated() throws Exception {
+ // when & then
+ mockMvc.perform(get("/api/contents")
+ .param("limit", "20")
+ .param("sortDirection", "DESCENDING")
+ .param("sortBy", "createdAt"))
+ .andExpect(status().isUnauthorized());
+ }
+ }
+
+ @Nested
+ @DisplayName("DELETE /api/contents/{id} - 콘텐츠 삭제")
+ class DeleteContent {
+
+ @Test
+ @DisplayName("ADMIN이 삭제하면 204와 함께 DB에서 제거되고 썸네일은 DELETED로 마킹된다")
+ void delete_persists() throws Exception {
+ // given
+ Content content = persistContent(ContentType.MOVIE, "삭제 영화", null, "액션");
+ // 스토리지 업로드 없이 이미 저장된 썸네일을 붙여 삭제 시 상태 전이만 검증한다
+ BinaryContent thumbnail = binaryContentRepository.save(
+ BinaryContent.completed("http://localhost/thumbnails/old.jpg"));
+ content.attachThumbnail(thumbnail);
+ contentRepository.saveAndFlush(content);
+ UUID thumbnailId = thumbnail.getId();
+
+ // when & then
+ mockMvc.perform(delete("/api/contents/{id}", content.getId())
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isNoContent());
+
+ assertThat(contentRepository.findById(content.getId())).isEmpty();
+ BinaryContent reloaded = binaryContentRepository.findById(thumbnailId).orElseThrow();
+ assertThat(reloaded.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.DELETED);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 콘텐츠 삭제면 404를 반환한다")
+ void delete_notFound() throws Exception {
+ // when & then
+ mockMvc.perform(delete("/api/contents/{id}", UUID.randomUUID())
+ .with(csrf())
+ .with(authentication(adminAuth())))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException"));
+ }
+
+ @Test
+ @DisplayName("USER 권한이면 403과 함께 삭제되지 않는다")
+ void delete_asUser_forbidden() throws Exception {
+ // given
+ Content content = persistContent(ContentType.MOVIE, "삭제 영화", null, "액션");
+
+ // when & then
+ mockMvc.perform(delete("/api/contents/{id}", content.getId())
+ .with(csrf())
+ .with(authentication(userAuth())))
+ .andExpect(status().isForbidden());
+
+ assertThat(contentRepository.findById(content.getId())).isPresent();
+ }
+
+ @Test
+ @DisplayName("인증 없이 삭제하면 401을 반환한다")
+ void delete_unauthenticated_unauthorized() throws Exception {
+ // when & then
+ mockMvc.perform(delete("/api/contents/{id}", UUID.randomUUID())
+ .with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+ }
+}
diff --git a/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeIntegrationTest.java
new file mode 100644
index 00000000..e8f91679
--- /dev/null
+++ b/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeIntegrationTest.java
@@ -0,0 +1,175 @@
+package com.codeit.team5.mopl.content.facade;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+
+import com.codeit.team5.mopl.TestcontainersConfiguration;
+import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
+import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
+import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
+import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
+import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
+import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
+import com.codeit.team5.mopl.content.dto.response.ContentResponse;
+import com.codeit.team5.mopl.content.entity.Content;
+import com.codeit.team5.mopl.content.entity.ContentType;
+import com.codeit.team5.mopl.content.exception.ContentNotFoundException;
+import com.codeit.team5.mopl.content.exception.EmptyTagException;
+import com.codeit.team5.mopl.content.repository.ContentRepository;
+import com.codeit.team5.mopl.global.dto.FileRequest;
+import com.codeit.team5.mopl.tag.repository.TagRepository;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+/**
+ * ContentFacade 통합 테스트.
+ *
+ * ContentController 통합 테스트에서 다루지 않은 썸네일 업로드 및 실패 시 롤백 경로를 검증한다.
+ * ContentFacade → UploadWithRollback → BinaryContentService → ContentService 전체 흐름을 실제 빈으로
+ * 주입하고, 외부 S3 스토리지({@link BinaryContentStorage})만 목으로 대체한다.
+ *
+ * 롤백(업로드 후 후속 작업 실패 시 스토리지 객체 삭제)은 서비스의 트랜잭션 경계와 비동기 이벤트에
+ * 의존하므로, 테스트 트랜잭션이 이를 삼키지 않도록 클래스에 {@code @Transactional}을 두지 않고
+ * {@link AfterEach}로 정리한다.
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Import(TestcontainersConfiguration.class)
+class ContentFacadeIntegrationTest {
+
+ private static final String UPLOADED_URL = "http://localhost/thumbnails/uploaded.jpg";
+
+ @Autowired
+ private ContentFacade contentFacade;
+
+ @Autowired
+ private ContentRepository contentRepository;
+
+ @Autowired
+ private BinaryContentRepository binaryContentRepository;
+
+ @Autowired
+ private TagRepository tagRepository;
+
+ // 실제 S3 호출을 막기 위해 스토리지 계층만 목으로 대체한다.
+ @MockitoBean
+ private BinaryContentStorage binaryContentStorage;
+
+ @AfterEach
+ void cleanUp() {
+ contentRepository.deleteAll();
+ binaryContentRepository.deleteAll();
+ tagRepository.deleteAll();
+ }
+
+ private ContentCreateRequest createRequest(List tags) {
+ return new ContentCreateRequest(ContentType.MOVIE, "테스트 영화", "설명", tags);
+ }
+
+ private FileRequest image(String filename) {
+ return new FileRequest(new byte[]{1, 2, 3}, filename);
+ }
+
+ @Test
+ @DisplayName("썸네일과 함께 생성하면 스토리지에 업로드되고 COMPLETED 썸네일이 콘텐츠에 연결된다")
+ void create_withThumbnail_uploadsAndAttaches() {
+ // given
+ given(binaryContentStorage.toUrl(anyString())).willReturn(UPLOADED_URL);
+
+ // when
+ ContentResponse response = contentFacade.create(createRequest(List.of("액션")), image("poster.jpg"));
+
+ // then
+ assertThat(response.thumbnailUrl()).isEqualTo(UPLOADED_URL);
+ verify(binaryContentStorage).store(anyString(), any(), any());
+
+ Content saved = contentRepository.findWithStatsAndTagsById(response.id()).orElseThrow();
+ assertThat(saved.getThumbnail()).isNotNull();
+ assertThat(saved.getThumbnail().getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED);
+ assertThat(saved.getThumbnail().getUrl()).isEqualTo(UPLOADED_URL);
+ }
+
+ @Test
+ @DisplayName("썸네일 없이 생성하면 스토리지를 호출하지 않고 thumbnailUrl이 null이다")
+ void create_withoutThumbnail_noStorageInteraction() {
+ // when
+ ContentResponse response = contentFacade.create(createRequest(List.of("액션")), null);
+
+ // then
+ assertThat(response.thumbnailUrl()).isNull();
+ verify(binaryContentStorage, never()).store(anyString(), any(), any());
+
+ Content saved = contentRepository.findWithStatsAndTagsById(response.id()).orElseThrow();
+ assertThat(saved.getThumbnail()).isNull();
+ }
+
+ @Test
+ @DisplayName("수정 시 새 썸네일을 올리면 기존 썸네일은 DELETED로 마킹되고 새 썸네일이 연결된다")
+ void update_withNewThumbnail_replacesOld() {
+ // given
+ given(binaryContentStorage.toUrl(anyString())).willReturn(UPLOADED_URL);
+ ContentResponse created = contentFacade.create(createRequest(List.of("액션")), image("old.jpg"));
+ ContentUpdateRequest updateRequest = new ContentUpdateRequest("수정된 영화", "수정된 설명", List.of("액션"));
+
+ // when
+ contentFacade.update(created.id(), updateRequest, image("new.jpg"));
+
+ // then
+ Content updated = contentRepository.findWithStatsAndTagsById(created.id()).orElseThrow();
+ assertThat(updated.getThumbnail().getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED);
+ // 기존 + 신규 = 2개의 BinaryContent, 그중 정확히 1개가 DELETED로 마킹되어야 한다
+ List all = binaryContentRepository.findAll();
+ assertThat(all).hasSize(2);
+ assertThat(all).filteredOn(bc -> bc.getUploadStatus() == BinaryContentUploadStatus.DELETED).hasSize(1);
+ assertThat(all).filteredOn(bc -> bc.getUploadStatus() == BinaryContentUploadStatus.COMPLETED).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("업로드 후 후속 작업이 실패하면 콘텐츠가 저장되지 않고 업로드된 스토리지 객체가 롤백 삭제된다")
+ void create_persistFailsAfterUpload_rollsBackStorage() {
+ // given - 업로드는 성공하지만 태그가 비어 콘텐츠 생성 단계에서 EmptyTagException이 발생한다
+ given(binaryContentStorage.toUrl(anyString())).willReturn(UPLOADED_URL);
+
+ // when & then
+ assertThatThrownBy(() -> contentFacade.create(createRequest(List.of()), image("poster.jpg")))
+ .isInstanceOf(EmptyTagException.class);
+
+ // 업로드는 시도되었고
+ verify(binaryContentStorage).store(anyString(), any(), any());
+ // 후속 작업 실패로 비동기 롤백 삭제가 호출되어야 한다
+ verify(binaryContentStorage, timeout(3000)).delete(anyString());
+ // 콘텐츠와 BinaryContent는 트랜잭션 롤백으로 저장되지 않아야 한다
+ assertThat(contentRepository.count()).isZero();
+ assertThat(binaryContentRepository.count()).isZero();
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 콘텐츠를 썸네일과 함께 수정하면 예외가 발생하고 업로드가 롤백된다")
+ void update_notFound_rollsBackStorage() {
+ // given
+ given(binaryContentStorage.toUrl(anyString())).willReturn(UPLOADED_URL);
+ ContentUpdateRequest updateRequest = new ContentUpdateRequest("수정", null, List.of("액션"));
+
+ // when & then
+ assertThatThrownBy(() -> contentFacade.update(UUID.randomUUID(), updateRequest, image("poster.jpg")))
+ .isInstanceOf(ContentNotFoundException.class);
+
+ verify(binaryContentStorage).store(anyString(), any(), any());
+ verify(binaryContentStorage, timeout(3000)).delete(anyString());
+ assertThat(binaryContentRepository.count()).isZero();
+ }
+}
diff --git a/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java
index 023fac4a..4fd95bbc 100644
--- a/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java
+++ b/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java
@@ -3,19 +3,26 @@
import static org.assertj.core.api.Assertions.assertThat;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
+import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest;
import com.codeit.team5.mopl.content.entity.Content;
+import com.codeit.team5.mopl.content.entity.ContentSortByType;
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.global.support.base.BaseRepositoryTest;
import com.codeit.team5.mopl.tag.entity.Tag;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Sort;
+import org.springframework.test.util.ReflectionTestUtils;
class ContentRepositoryTest extends BaseRepositoryTest {
@@ -27,6 +34,8 @@ void setUp() {
clear();
}
+ // --- findWithStatsAndTagsById (EntityGraph) ---
+
@Test
@DisplayName("stats, contentTags, tag를 1번 쿼리로 fetch join 조회_성공")
void findWithStatsAndTagsById_성공() {
@@ -112,6 +121,294 @@ void findWithStatsAndTagsById_NotFound() {
ensureQueryCount(1);
}
+ // --- findContents / countContents (QueryDSL: 필터·정렬·커서) ---
+
+ @Test
+ @DisplayName("typeEqual 필터는 해당 타입의 콘텐츠만 조회한다")
+ void findContents_filterByType() {
+ // given
+ persistContent(ContentType.MOVIE, "영화1", null);
+ persistContent(ContentType.MOVIE, "영화2", null);
+ persistContent(ContentType.TV_SERIES, "드라마1", null);
+ clear();
+
+ // when
+ List result = contentRepository.findContents(
+ request(ContentType.MOVIE, null, null, null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT), 10);
+
+ // then
+ assertThat(result).hasSize(2)
+ .extracting(Content::getType)
+ .containsOnly(ContentType.MOVIE);
+ }
+
+ @Test
+ @DisplayName("keywordLike 필터는 제목 또는 설명에 키워드가 포함된 콘텐츠를 조회한다")
+ void findContents_filterByKeyword() {
+ // given
+ persistContent(ContentType.MOVIE, "어벤져스", "히어로 영화");
+ persistContent(ContentType.MOVIE, "인터스텔라", "우주를 다룬 어벤져스급 대작"); // 설명에 키워드
+ persistContent(ContentType.MOVIE, "기생충", "가족 드라마");
+ clear();
+
+ // when
+ List result = contentRepository.findContents(
+ request(null, "어벤져스", null, null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT), 10);
+
+ // then - 제목 매칭 1건 + 설명 매칭 1건
+ assertThat(result).hasSize(2)
+ .extracting(Content::getTitle)
+ .containsExactlyInAnyOrder("어벤져스", "인터스텔라");
+ }
+
+ @Test
+ @DisplayName("tagsIn 필터는 해당 태그를 가진 콘텐츠만 조회한다")
+ void findContents_filterByTagsIn() {
+ // given
+ Tag action = persistAndFlush(Tag.create("액션"));
+ Tag romance = persistAndFlush(Tag.create("로맨스"));
+ Content movie1 = persistContent(ContentType.MOVIE, "액션영화", null);
+ Content movie2 = persistContent(ContentType.MOVIE, "로맨스영화", null);
+ attachTag(movie1, action);
+ attachTag(movie2, romance);
+ clear();
+
+ // when
+ List result = contentRepository.findContents(
+ request(null, null, List.of("액션"), null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT), 10);
+
+ // then
+ assertThat(result).hasSize(1)
+ .extracting(Content::getTitle)
+ .containsExactly("액션영화");
+ }
+
+ @Test
+ @DisplayName("tagsIn 필터는 대소문자·공백을 정규화하여 매칭한다")
+ void findContents_filterByTagsIn_normalized() {
+ // given
+ Tag sf = persistAndFlush(Tag.create("sf"));
+ Content movie = persistContent(ContentType.MOVIE, "SF영화", null);
+ attachTag(movie, sf);
+ clear();
+
+ // when - 대문자·공백이 포함된 요청도 정규화되어 매칭되어야 한다
+ List result = contentRepository.findContents(
+ request(null, null, List.of(" SF "), null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT), 10);
+
+ // then
+ assertThat(result).hasSize(1)
+ .extracting(Content::getTitle)
+ .containsExactly("SF영화");
+ }
+
+ @Test
+ @DisplayName("여러 필터를 함께 적용하면 모든 조건을 만족하는 콘텐츠만 조회한다")
+ void findContents_combinedFilters() {
+ // given
+ persistContent(ContentType.MOVIE, "어벤져스", null);
+ persistContent(ContentType.TV_SERIES, "어벤져스 시리즈", null); // 키워드는 맞지만 타입 불일치
+ persistContent(ContentType.MOVIE, "기생충", null); // 타입은 맞지만 키워드 불일치
+ clear();
+
+ // when
+ List result = contentRepository.findContents(
+ request(ContentType.MOVIE, "어벤져스", null, null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT), 10);
+
+ // then
+ assertThat(result).hasSize(1)
+ .extracting(Content::getTitle)
+ .containsExactly("어벤져스");
+ }
+
+ @Test
+ @DisplayName("countContents는 필터 조건에 맞는 콘텐츠 개수를 반환한다")
+ void countContents_withFilter() {
+ // given
+ persistContent(ContentType.MOVIE, "영화1", null);
+ persistContent(ContentType.MOVIE, "영화2", null);
+ persistContent(ContentType.TV_SERIES, "드라마1", null);
+ clear();
+
+ // when
+ long movieCount = contentRepository.countContents(
+ request(ContentType.MOVIE, null, null, null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT));
+ long totalCount = contentRepository.countContents(
+ request(null, null, null, null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT));
+
+ // then
+ assertThat(movieCount).isEqualTo(2);
+ assertThat(totalCount).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("createdAt DESC 커서로 콘텐츠를 중복·누락 없이 이어서 조회한다")
+ void findContents_createdAtDescCursor() {
+ // given
+ persistContent(ContentType.MOVIE, "콘텐츠A", null);
+ persistContent(ContentType.MOVIE, "콘텐츠B", null);
+ persistContent(ContentType.MOVIE, "콘텐츠C", null);
+ clear();
+
+ ContentCursorRequest firstRequest =
+ request(null, null, null, null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT);
+
+ // when
+ List expectedOrder = contentRepository.findContents(firstRequest, 10);
+ List firstPage = contentRepository.findContents(firstRequest, 1);
+ Content last = firstPage.get(0);
+ ContentCursorRequest secondRequest = request(
+ null, null, null,
+ last.getCreatedAt().toString(), last.getId().toString(),
+ Sort.Direction.DESC, ContentSortByType.CREATED_AT);
+ List secondPage = contentRepository.findContents(secondRequest, 10);
+
+ // then
+ assertPagination(expectedOrder, firstPage, secondPage, 3);
+ }
+
+ @Test
+ @DisplayName("createdAt ASC 정렬은 DESC 정렬의 정확한 역순으로 조회한다")
+ void findContents_createdAtAsc() {
+ // given
+ persistContent(ContentType.MOVIE, "콘텐츠A", null);
+ persistContent(ContentType.MOVIE, "콘텐츠B", null);
+ persistContent(ContentType.MOVIE, "콘텐츠C", null);
+ clear();
+
+ // when
+ List desc = contentRepository.findContents(
+ request(null, null, null, null, null, Sort.Direction.DESC, ContentSortByType.CREATED_AT), 10);
+ List asc = contentRepository.findContents(
+ request(null, null, null, null, null, Sort.Direction.ASC, ContentSortByType.CREATED_AT), 10);
+
+ // then - (createdAt, id) 동일 정렬 기준에서 방향만 반대이므로 ASC는 DESC의 정확한 역순이다
+ List reversedDescIds = new ArrayList<>(desc.stream().map(Content::getId).toList());
+ Collections.reverse(reversedDescIds);
+ assertThat(asc)
+ .extracting(Content::getId)
+ .containsExactlyElementsOf(reversedDescIds);
+ }
+
+ @Test
+ @DisplayName("watcherCount DESC 커서로 콘텐츠를 중복·누락 없이 이어서 조회한다")
+ void findContents_watcherCountDescCursor() {
+ // given
+ persistContentWithWatcher("시청자10", 10L);
+ persistContentWithWatcher("시청자30", 30L);
+ persistContentWithWatcher("시청자20", 20L);
+ clear();
+
+ ContentCursorRequest firstRequest =
+ request(null, null, null, null, null, Sort.Direction.DESC, ContentSortByType.WATCHER_COUNT);
+
+ // when
+ List expectedOrder = contentRepository.findContents(firstRequest, 10);
+ List firstPage = contentRepository.findContents(firstRequest, 1);
+ Content last = firstPage.get(0);
+ ContentCursorRequest secondRequest = request(
+ null, null, null,
+ String.valueOf(last.getStats().getWatcherCount()), last.getId().toString(),
+ Sort.Direction.DESC, ContentSortByType.WATCHER_COUNT);
+ List secondPage = contentRepository.findContents(secondRequest, 10);
+
+ // then - watcherCount 내림차순(30, 20, 10)
+ assertThat(expectedOrder)
+ .extracting(c -> c.getStats().getWatcherCount())
+ .containsExactly(30L, 20L, 10L);
+ assertPagination(expectedOrder, firstPage, secondPage, 3);
+ }
+
+ @Test
+ @DisplayName("watcherCount가 동률인 경우 id를 2차 정렬 키로 삼아 중복·누락 없이 이어서 조회한다")
+ void findContents_watcherCountDescCursor_withTie() {
+ // given - 두 콘텐츠가 동일한 watcherCount(20)를 가진다
+ persistContentWithWatcher("동률A", 20L);
+ persistContentWithWatcher("동률B", 20L);
+ persistContentWithWatcher("단독", 10L);
+ clear();
+
+ ContentCursorRequest firstRequest =
+ request(null, null, null, null, null, Sort.Direction.DESC, ContentSortByType.WATCHER_COUNT);
+
+ // when - 1페이지 마지막 행이 동률 값(20)을 커서로 넘긴다
+ List expectedOrder = contentRepository.findContents(firstRequest, 10);
+ List firstPage = contentRepository.findContents(firstRequest, 1);
+ Content last = firstPage.get(0);
+ ContentCursorRequest secondRequest = request(
+ null, null, null,
+ String.valueOf(last.getStats().getWatcherCount()), last.getId().toString(),
+ Sort.Direction.DESC, ContentSortByType.WATCHER_COUNT);
+ List secondPage = contentRepository.findContents(secondRequest, 10);
+
+ // then - watcherCount가 같은 나머지 한 행이 누락·중복 없이 다음 페이지에 나와야 한다
+ assertThat(expectedOrder)
+ .extracting(c -> c.getStats().getWatcherCount())
+ .containsExactly(20L, 20L, 10L);
+ assertPagination(expectedOrder, firstPage, secondPage, 3);
+ }
+
+ @Test
+ @DisplayName("rate(평균 평점) DESC 커서로 콘텐츠를 중복·누락 없이 이어서 조회한다")
+ void findContents_rateDescCursor() {
+ // given - 평균 평점: 2.0, 4.5, 3.0
+ persistContentWithRating("평점2.0", 4.0, 2);
+ persistContentWithRating("평점4.5", 9.0, 2);
+ persistContentWithRating("평점3.0", 9.0, 3);
+ clear();
+
+ ContentCursorRequest firstRequest =
+ request(null, null, null, null, null, Sort.Direction.DESC, ContentSortByType.RATE);
+
+ // when
+ List expectedOrder = contentRepository.findContents(firstRequest, 10);
+ List firstPage = contentRepository.findContents(firstRequest, 1);
+ Content last = firstPage.get(0);
+ ContentCursorRequest secondRequest = request(
+ null, null, null,
+ String.valueOf(last.getStats().getAverageRating()), last.getId().toString(),
+ Sort.Direction.DESC, ContentSortByType.RATE);
+ List secondPage = contentRepository.findContents(secondRequest, 10);
+
+ // then - 평균 평점 내림차순(4.5, 3.0, 2.0)
+ assertThat(expectedOrder)
+ .extracting(c -> c.getStats().getAverageRating())
+ .containsExactly(4.5, 3.0, 2.0);
+ assertPagination(expectedOrder, firstPage, secondPage, 3);
+ }
+
+ @Test
+ @DisplayName("rate가 동률인 경우 id를 2차 정렬 키로 삼아 중복·누락 없이 이어서 조회한다")
+ void findContents_rateDescCursor_withTie() {
+ // given - 최상위 두 콘텐츠가 동일한 평균 평점(4.0)을 가진다 (ratingSum/reviewCount 조합은 다름)
+ // 1페이지(size=1)의 커서가 동률 값이 되어야 tie-break 분기를 실제로 검증할 수 있다
+ persistContentWithRating("동률A", 8.0, 2);
+ persistContentWithRating("동률B", 12.0, 3);
+ persistContentWithRating("단독", 4.0, 2);
+ clear();
+
+ ContentCursorRequest firstRequest =
+ request(null, null, null, null, null, Sort.Direction.DESC, ContentSortByType.RATE);
+
+ // when - 1페이지 마지막 행이 동률 값(4.0)을 커서로 넘긴다
+ List expectedOrder = contentRepository.findContents(firstRequest, 10);
+ List firstPage = contentRepository.findContents(firstRequest, 1);
+ Content last = firstPage.get(0);
+ ContentCursorRequest secondRequest = request(
+ null, null, null,
+ String.valueOf(last.getStats().getAverageRating()), last.getId().toString(),
+ Sort.Direction.DESC, ContentSortByType.RATE);
+ List secondPage = contentRepository.findContents(secondRequest, 10);
+
+ // then - 평균 평점이 같은 나머지 한 행이 누락·중복 없이 다음 페이지에 나와야 한다
+ assertThat(expectedOrder)
+ .extracting(c -> c.getStats().getAverageRating())
+ .containsExactly(4.0, 4.0, 2.0);
+ assertPagination(expectedOrder, firstPage, secondPage, 3);
+ }
+
+ // --- 헬퍼 ---
+
private Content createContent() {
Content content = Content.createByExternalSource(
ContentType.MOVIE,
@@ -124,4 +421,61 @@ private Content createContent() {
);
return persistAndFlush(content);
}
+
+ private Content persistContent(ContentType type, String title, String description) {
+ Content content = persistAndFlush(Content.createByAdmin(type, title, description));
+ ContentStats stats = persistAndFlush(ContentStats.create(content));
+ content.attachStats(stats);
+ return content;
+ }
+
+ private Content persistContentWithWatcher(String title, long watcherCount) {
+ Content content = persistAndFlush(Content.createByAdmin(ContentType.MOVIE, title, null));
+ ContentStats stats = ContentStats.create(content);
+ ReflectionTestUtils.setField(stats, "watcherCount", watcherCount);
+ persistAndFlush(stats);
+ content.attachStats(stats);
+ return content;
+ }
+
+ private Content persistContentWithRating(String title, double ratingSum, int reviewCount) {
+ Content content = persistAndFlush(Content.createByAdmin(ContentType.MOVIE, title, null));
+ ContentStats stats = ContentStats.create(content);
+ stats.updateRating(ratingSum, reviewCount);
+ persistAndFlush(stats);
+ content.attachStats(stats);
+ return content;
+ }
+
+ private void attachTag(Content content, Tag tag) {
+ ContentTag contentTag = ContentTag.create(content, tag);
+ content.addTag(contentTag);
+ persistAndFlush(contentTag);
+ }
+
+ private ContentCursorRequest request(
+ ContentType typeEqual, String keywordLike, List tagsIn,
+ String cursor, String idAfter, Sort.Direction direction, ContentSortByType sortBy) {
+ return new ContentCursorRequest(typeEqual, keywordLike, tagsIn, cursor, idAfter, 10, direction, sortBy);
+ }
+
+ /**
+ * 1페이지(size=1) + 2페이지(커서)를 합치면 전체 순서와 정확히 일치하고,
+ * 두 페이지 간 중복이 없어야 함을 검증한다.
+ */
+ private void assertPagination(List expectedOrder, List firstPage,
+ List secondPage, int totalSize) {
+ assertThat(expectedOrder).hasSize(totalSize);
+
+ List combined = new ArrayList<>(firstPage);
+ combined.addAll(secondPage);
+ assertThat(combined)
+ .extracting(Content::getId)
+ .containsExactlyElementsOf(expectedOrder.stream().map(Content::getId).toList());
+ assertThat(firstPage)
+ .extracting(Content::getId)
+ .doesNotContainAnyElementsOf(secondPage.stream().map(Content::getId).toList());
+ assertThat(firstPage).containsExactly(expectedOrder.get(0));
+ assertThat(secondPage.get(0)).isEqualTo(expectedOrder.get(1));
+ }
}
diff --git a/src/test/java/com/codeit/team5/mopl/global/support/security/IntegrationTestSecuritySupport.java b/src/test/java/com/codeit/team5/mopl/global/support/security/IntegrationTestSecuritySupport.java
new file mode 100644
index 00000000..ff47772c
--- /dev/null
+++ b/src/test/java/com/codeit/team5/mopl/global/support/security/IntegrationTestSecuritySupport.java
@@ -0,0 +1,35 @@
+package com.codeit.team5.mopl.global.support.security;
+
+import com.codeit.team5.mopl.auth.security.details.AuthUser;
+import com.codeit.team5.mopl.auth.security.details.MoplUserDetails;
+import java.util.UUID;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+
+/**
+ * 통합 테스트에서 {@code SecurityMockMvcRequestPostProcessors.authentication(...)}에 주입할
+ * {@link Authentication}을 만들기 위한 공용 헬퍼. 여러 컨트롤러 통합 테스트에서 반복되던
+ * ADMIN/USER 인증 객체 생성 로직을 한 곳으로 모은다.
+ */
+public final class IntegrationTestSecuritySupport {
+
+ private IntegrationTestSecuritySupport() {
+ }
+
+ public static Authentication adminAuthentication() {
+ return authenticationOf("admin@mopl.com", "ADMIN");
+ }
+
+ public static Authentication userAuthentication() {
+ return authenticationOf("user@mopl.com", "USER");
+ }
+
+ public static Authentication authenticationOf(String email, String role) {
+ return authenticationOf(UUID.randomUUID(), email, role);
+ }
+
+ public static Authentication authenticationOf(UUID userId, String email, String role) {
+ MoplUserDetails details = new MoplUserDetails(new AuthUser(userId, email, role, false), "password");
+ return new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities());
+ }
+}