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()); + } +}