test(content): 콘텐츠 전체보기 E2E 검증을 추가한다

This commit is contained in:
2026-06-27 07:32:50 +09:00
parent 0686dd6eb3
commit 55abbd2a6d
2 changed files with 214 additions and 2 deletions

View File

@@ -743,7 +743,7 @@ private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageRespons
### Phase 5: End-to-End 검증 ### Phase 5: End-to-End 검증
- [ ] **Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성** - [x] **Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성**
- Files: - Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt` - 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 실패 테스트를 작성한다. - 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 테스트를 통과시킨다. - GREEN: Phase 1~4 구현을 통합해 E2E 테스트를 통과시킨다.
- 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest` - 통과 확인 명령: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest`
- REFACTOR: 테스트 데이터가 다른 추천 테스트와 충돌하지 않도록 각 테스트에서 저장한 데이터만 사용하고, 같은 테스트를 재실행한다. - 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: - Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/**` - 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` - 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`. - 기대 결과: 모든 명령 `BUILD SUCCESSFUL`.
- GREEN: 실패가 있으면 해당 task 문서 체크박스를 되돌리고 RED/GREEN 단계로 돌아가 수정한다. - GREEN: 실패가 있으면 해당 task 문서 체크박스를 되돌리고 RED/GREEN 단계로 돌아가 수정한다.
- REFACTOR: `./gradlew ktlintCheck`를 실행하고 `BUILD SUCCESSFUL`을 확인한다. - 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`.
--- ---

View File

@@ -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
)
}