diff --git a/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md index 8f56ca3a..0a65a823 100644 --- a/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md +++ b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md @@ -521,7 +521,7 @@ interface MainContentAllQueryPort { ### Phase 5: 공개 API 통합 검증 -- [ ] **Task 5.1: controller-to-repository 통합 테스트 작성** +- [x] **Task 5.1: controller-to-repository 통합 테스트 작성** - Files: - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` - RED: Spring context 기반으로 `GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20`가 `audios`, `totalCount`, `sort`, `page`, `size`, `hasNext`를 반환하고 `series`는 빈 배열인 테스트를 작성한다. @@ -531,8 +531,11 @@ interface MainContentAllQueryPort { - GREEN: 테스트 fixture에 공개/비공개/성인/무료/포인트/요일별 시리즈/오리지널 시리즈/차단 관계 데이터를 구성하고 end-to-end 응답을 통과시킨다. - REFACTOR: controller, facade, service, repository 경계가 단방향 의존을 유지하는지 import를 확인한다. - 기대 결과: 실제 HTTP 경로에서 PRD의 주요 응답 계약이 검증된다. + - 검증 기록: + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 `AUDIO`, `SERIES dayOfWeek=MON`, `ORIGINAL dayOfWeek 무시` HTTP 통합 경로를 확인했다. + - 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으므로, 별도 production 수정은 없었다. -- [ ] **Task 5.2: 회귀 테스트와 포맷 검증** +- [x] **Task 5.2: 회귀 테스트와 포맷 검증** - Files: - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/**` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/**` @@ -549,6 +552,12 @@ interface MainContentAllQueryPort { - GREEN: 위 명령이 모두 성공하고, 응답 DTO에 제거 대상 필드가 남아 있지 않음을 확인한다. - REFACTOR: 검증 결과를 이 문서 하단 `검증 기록`에 누적한다. - 기대 결과: 신규 API 패키지 테스트와 포맷 검증이 완료된다. + - 검증 기록: + - GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공. + - GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공. + - GREEN: `./gradlew ktlintCheck` 성공. + - GREEN: `git diff --check` 성공. + - 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다. --- @@ -594,3 +603,12 @@ interface MainContentAllQueryPort { - GREEN: `git diff --check` 성공. - 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, DTO 테스트의 부재 검증만 검색되었다. - 확인: 위 리뷰 항목 2건은 보강 테스트와 구현 수정으로 해결했다. +- 2026-06-25 Phase 5 공개 API 통합 검증 + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 실제 HTTP 경로에서 `AUDIO`는 `audios`와 빈 `series`, `SERIES dayOfWeek=MON`은 `series`와 빈 `audios`, `ORIGINAL dayOfWeek=MON`은 `dayOfWeek=null`과 오리지널 시리즈만 반환함을 확인했다. + - 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으며, Phase 5에서 production 코드는 변경하지 않았다. + - 참고: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'`와 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`를 동시에 실행했을 때 test result XML 파일 쓰기 충돌이 한 번 발생했다. 동일 명령을 순차 재실행해 두 테스트 모두 성공함을 확인했다. + - GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` 성공. + - GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공. + - GREEN: `./gradlew ktlintCheck` 성공. + - GREEN: `git diff --check` 성공. + - 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다. diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt new file mode 100644 index 00000000..7cfd95bc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt @@ -0,0 +1,260 @@ +package kr.co.vividnext.sodalive.v2.api.content.all.adapter.`in`.web + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.DisplayName +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.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:main-content-all-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class MainContentAllEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("전체 탭 AUDIO API는 controller-service-repository를 거쳐 오디오 응답과 빈 series를 반환한다") + fun shouldReturnAudioContentsThroughControllerServiceAndRepository() { + val fixture = createAudioFixture("main-all-audio-e2e") + + mockMvc.perform( + get("/api/v2/audio/contents") + .param("type", "AUDIO") + .param("sort", "LATEST") + .param("page", "0") + .param("size", "20") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.type").value("AUDIO")) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.audios[0].title").value("main-all-audio-e2e-audio")) + .andExpect(jsonPath("$.data.audios[0].imageUrl").value("https://cdn.test/main-all-audio-e2e-audio.png")) + .andExpect(jsonPath("$.data.audios[0].creatorNickname").value("main-all-audio-e2e-creator")) + .andExpect(jsonPath("$.data.series").isEmpty) + .andExpect(jsonPath("$.data.sort").value("LATEST")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다") + fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() { + val fixture = createSeriesFixture("main-all-series-e2e", SeriesPublishedDaysOfWeek.MON, isOriginal = false) + + mockMvc.perform( + get("/api/v2/audio/contents") + .param("type", "SERIES") + .param("dayOfWeek", "MON") + .param("sort", "POPULAR") + .param("page", "0") + .param("size", "20") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.type").value("SERIES")) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.audios").isEmpty) + .andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId)) + .andExpect(jsonPath("$.data.series[0].title").value("main-all-series-e2e-series")) + .andExpect(jsonPath("$.data.series[0].coverImageUrl").value("https://cdn.test/main-all-series-e2e-series.png")) + .andExpect(jsonPath("$.data.series[0].creatorNickname").value("main-all-series-e2e-creator")) + .andExpect(jsonPath("$.data.series[0].isOriginal").value(false)) + .andExpect(jsonPath("$.data.dayOfWeek").value("MON")) + .andExpect(jsonPath("$.data.sort").value("POPULAR")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("전체 탭 ORIGINAL API는 dayOfWeek를 무시하고 오리지널 시리즈만 반환한다") + fun shouldReturnOriginalSeriesIgnoringDayOfWeekThroughControllerServiceAndRepository() { + createSeriesFixture("main-all-original-control-e2e", SeriesPublishedDaysOfWeek.MON, isOriginal = false) + val fixture = createSeriesFixture("main-all-original-e2e", SeriesPublishedDaysOfWeek.TUE, isOriginal = true) + + mockMvc.perform( + get("/api/v2/audio/contents") + .param("type", "ORIGINAL") + .param("dayOfWeek", "MON") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.type").value("ORIGINAL")) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.audios").isEmpty) + .andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId)) + .andExpect(jsonPath("$.data.series[0].title").value("main-all-original-e2e-series")) + .andExpect(jsonPath("$.data.series[0].isOriginal").value(true)) + .andExpect(jsonPath("$.data.dayOfWeek").value(nullValue())) + .andExpect(jsonPath("$.data.sort").value("LATEST")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + private fun createAudioFixture(prefix: String): AudioFixture { + return transactionTemplate.execute { + val creator = saveMember("$prefix-creator", MemberRole.CREATOR) + val theme = saveTheme("$prefix-theme") + val audio = saveAudioContent( + creator = creator, + theme = theme, + title = "$prefix-audio", + coverImage = "$prefix-audio.png", + releaseDate = LocalDateTime.now().minusHours(1), + price = 100 + ) + entityManager.flush() + entityManager.clear() + + AudioFixture(audioContentId = audio.id!!) + }!! + } + + private fun createSeriesFixture( + prefix: String, + dayOfWeek: SeriesPublishedDaysOfWeek, + isOriginal: Boolean + ): SeriesFixture { + return transactionTemplate.execute { + val creator = saveMember("$prefix-creator", MemberRole.CREATOR) + val series = saveSeries("$prefix-series", creator, dayOfWeek, isOriginal) + if (isOriginal) { + val theme = saveTheme("$prefix-theme") + val audio = saveAudioContent( + creator = creator, + theme = theme, + title = "$prefix-audio", + coverImage = "$prefix-audio.png", + releaseDate = LocalDateTime.now().minusHours(1), + price = 100 + ) + saveOrder(saveMember("$prefix-buyer", MemberRole.USER), creator, audio) + } + entityManager.flush() + entityManager.clear() + + SeriesFixture(seriesId = series.id!!) + }!! + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveTheme(name: String): AudioContentTheme { + val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = true) + entityManager.persist(theme) + return theme + } + + private fun saveAudioContent( + creator: Member, + theme: AudioContentTheme, + title: String, + coverImage: String, + releaseDate: LocalDateTime, + price: Int + ): AudioContent { + val content = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate, + price = price, + isAdult = false, + isPointAvailable = true + ) + content.member = creator + content.theme = theme + content.isActive = true + content.coverImage = coverImage + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun saveSeries( + title: String, + creator: Member, + dayOfWeek: SeriesPublishedDaysOfWeek, + isOriginal: Boolean + ): Series { + val series = Series( + title = title, + introduction = "introduction", + languageCode = "ko", + isAdult = false, + isOriginal = isOriginal + ) + series.member = creator + series.genre = saveSeriesGenre(title) + series.coverImage = "$title.png" + series.publishedDaysOfWeek.add(dayOfWeek) + entityManager.persist(series) + return series + } + + private fun saveSeriesGenre(name: String): SeriesGenre { + val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true) + entityManager.persist(genre) + return genre + } + + private fun saveOrder(member: Member, creator: Member, content: AudioContent): Order { + val order = Order(type = OrderType.KEEP, isActive = true) + order.member = member + order.creator = creator + order.audioContent = content + order.can = 100 + entityManager.persist(order) + return order + } + + private data class AudioFixture( + val audioContentId: Long + ) + + private data class SeriesFixture( + val seriesId: Long + ) +}