test(content): 콘텐츠 전체보기 E2E 검증을 추가한다
This commit is contained in:
@@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user