From 55abbd2a6d9b8fc9ab8ba56fe27c5aed73e05e01 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 27 Jun 2026 07:32:50 +0900 Subject: [PATCH] =?UTF-8?q?test(content):=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=B3=B4=EA=B8=B0=20E2E=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260627_콘텐츠_전체보기_API/plan-task.md | 12 +- .../in/web/ContentOverviewEndToEndTest.kt | 204 ++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt diff --git a/docs/20260627_콘텐츠_전체보기_API/plan-task.md b/docs/20260627_콘텐츠_전체보기_API/plan-task.md index 6fcb44f9..ef939539 100644 --- a/docs/20260627_콘텐츠_전체보기_API/plan-task.md +++ b/docs/20260627_콘텐츠_전체보기_API/plan-task.md @@ -743,7 +743,7 @@ private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageRespons ### Phase 5: End-to-End 검증 -- [ ] **Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성** +- [x] **Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt` - RED: 인증 회원 기준 `NEW_AND_HOT_AUDIO`, `FIRST_AUDIO_CONTENT`가 `ApiResponse.ok`와 `items/page/size/hasNext`를 반환하는 E2E 실패 테스트를 작성한다. @@ -757,8 +757,11 @@ private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageRespons - GREEN: Phase 1~4 구현을 통합해 E2E 테스트를 통과시킨다. - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest` - REFACTOR: 테스트 데이터가 다른 추천 테스트와 충돌하지 않도록 각 테스트에서 저장한 데이터만 사용하고, 같은 테스트를 재실행한다. + - 검증 기록: + - E2E 테스트 작성: `ContentOverviewEndToEndTest`를 추가해 비회원 401, 인증 회원 `NEW_AND_HOT_AUDIO` 200, 인증 회원 `FIRST_AUDIO_CONTENT` 200, invalid type의 `NEW_AND_HOT_AUDIO` fallback을 검증했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest` 실행, `BUILD SUCCESSFUL`. -- [ ] **Task 5.2: 전체 관련 테스트와 ktlint 검증** +- [x] **Task 5.2: 전체 관련 테스트와 ktlint 검증** - Files: - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/**` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` @@ -772,6 +775,11 @@ private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageRespons - 기대 결과: 모든 명령 `BUILD SUCCESSFUL`. - GREEN: 실패가 있으면 해당 task 문서 체크박스를 되돌리고 RED/GREEN 단계로 돌아가 수정한다. - REFACTOR: `./gradlew ktlintCheck`를 실행하고 `BUILD SUCCESSFUL`을 확인한다. + - 검증 기록: + - 관련 테스트 묶음: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'` 실행, `BUILD SUCCESSFUL`. + - 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 실행, `BUILD SUCCESSFUL`. + - 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행, `BUILD SUCCESSFUL`. + - Lint: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL`. --- diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt new file mode 100644 index 00000000..6d7092b2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt @@ -0,0 +1,204 @@ +package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web + +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +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:content-overview-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 ContentOverviewEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("콘텐츠 전체보기 API는 비회원 요청을 거부한다") + fun shouldRejectAnonymousContentOverviewRequest() { + mockMvc.perform(get("/api/v2/contents")) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("콘텐츠 전체보기 API는 인증 회원에게 New & Hot 오디오 페이지를 반환한다") + fun shouldReturnNewAndHotAudioOverviewForMember() { + val fixture = createNewAndHotFixture("content-overview-new-hot") + + mockMvc.perform( + get("/api/v2/contents") + .param("type", "NEW_AND_HOT_AUDIO") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.type").value("NEW_AND_HOT_AUDIO")) + .andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.items[0].title").value("content-overview-new-hot-audio")) + .andExpect(jsonPath("$.data.items[0].coverImage").value("https://cdn.test/content-overview-new-hot.png")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("콘텐츠 전체보기 API는 인증 회원에게 첫 번째 오디오 콘텐츠 페이지를 반환한다") + fun shouldReturnFirstAudioContentOverviewForMember() { + val fixture = createFirstAudioFixture("content-overview-first") + + mockMvc.perform( + get("/api/v2/contents") + .param("type", "FIRST_AUDIO_CONTENT") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT")) + .andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.items[0].title").value("content-overview-first-audio")) + .andExpect(jsonPath("$.data.items[0].coverImage").value("https://cdn.test/content-overview-first.png")) + .andExpect(jsonPath("$.data.items[0].isFirstContent").value(true)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다") + fun shouldFallbackInvalidTypeToNewAndHotAudio() { + val fixture = createNewAndHotFixture("content-overview-invalid-type") + + mockMvc.perform( + get("/api/v2/contents") + .param("type", "UNKNOWN") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.type").value("NEW_AND_HOT_AUDIO")) + .andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + private fun createNewAndHotFixture(prefix: String): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.now().minusHours(1) + val viewer = saveMember("$prefix-viewer", MemberRole.USER) + val creator = saveMember("$prefix-creator", MemberRole.CREATOR) + val theme = saveTheme(prefix) + val audio = saveAudio(creator, theme, "$prefix-audio", "$prefix.png", now) + saveSnapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, audio.id!!, now) + entityManager.flush() + entityManager.clear() + + Fixture(viewer = viewer, audioContentId = audio.id!!) + }!! + } + + private fun createFirstAudioFixture(prefix: String): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.now().minusHours(1) + val viewer = saveMember("$prefix-viewer", MemberRole.USER) + val creator = saveMember("$prefix-creator", MemberRole.CREATOR) + val theme = saveTheme(prefix) + val audio = saveAudio(creator, theme, "$prefix-audio", "$prefix.png", now) + entityManager.flush() + entityManager.clear() + + Fixture(viewer = viewer, audioContentId = audio.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(prefix: String): AudioContentTheme { + val theme = AudioContentTheme(theme = "$prefix-theme", image = "$prefix-theme.png", isActive = true) + entityManager.persist(theme) + return theme + } + + private fun saveAudio( + creator: Member, + theme: AudioContentTheme, + title: String, + coverImage: String, + releaseDate: LocalDateTime + ): AudioContent { + val audio = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate, + isAdult = false, + price = 100, + isPointAvailable = true + ) + audio.member = creator + audio.theme = theme + audio.isActive = true + audio.coverImage = coverImage + audio.duration = "00:10" + entityManager.persist(audio) + return audio + } + + private fun saveSnapshot(sectionType: RecommendedSectionType, targetId: Long, snapshotAt: LocalDateTime) { + entityManager.persist( + RecommendationSnapshot( + sectionType = sectionType, + targetId = targetId, + score = 1.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.0 + ) + ) + } + + private data class Fixture( + val viewer: Member, + val audioContentId: Long + ) +}