test #426
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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