diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt new file mode 100644 index 00000000..5dde9096 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt @@ -0,0 +1,221 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.series.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.SeriesContent +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +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 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +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:creator-channel-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class CreatorChannelSeriesEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("시리즈 탭 API는 controller-service-repository를 거쳐 전체 응답 필드를 반환한다") + fun shouldReturnSeriesTabThroughControllerServiceAndRepository() { + val fixture = createFixture("series-e2e-success") + + mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/series") + .param("sort", "LATEST") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.seriesCount").value(1)) + .andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId)) + .andExpect(jsonPath("$.data.series[0].title").value("series-e2e-success-series")) + .andExpect(jsonPath("$.data.series[0].coverImageUrl").value("https://cdn.test/series-e2e-success-cover.png")) + .andExpect(jsonPath("$.data.series[0].publishedDaysOfWeek").value("매주 월, 목")) + .andExpect(jsonPath("$.data.series[0].isOriginal").value(true)) + .andExpect(jsonPath("$.data.series[0].isAdult").value(false)) + .andExpect(jsonPath("$.data.series[0].isProceeding").value(true)) + .andExpect(jsonPath("$.data.series[0].contentCount").value(2)) + .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("시리즈 탭 API는 잘못된 sort와 page size를 fallback하고 비크리에이터 구매 통계를 반환한다") + fun shouldFallbackRequestAndReturnPurchaseStatsForNonCreator() { + val fixture = createFixture("series-e2e-fallback") + + mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/series") + .param("sort", "INVALID") + .param("page", "-1") + .param("size", "10") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.series[0].purchasedContentCount").value(1)) + .andExpect(jsonPath("$.data.series[0].paidContentCount").value(2)) + .andExpect(jsonPath("$.data.series[0].purchasedPaidContentRate").value(50)) + .andExpect(jsonPath("$.data.sort").value("LATEST")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + } + + @Test + @DisplayName("시리즈 탭 API는 creator 본인 조회 시 구매 통계 필드를 null로 반환한다") + fun shouldHidePurchaseStatsForCreatorSelf() { + val fixture = createFixture("series-e2e-self") + + mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/series") + .with(user(MemberAdapter(fixture.creator))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.series[0].purchasedContentCount").value(nullValue())) + .andExpect(jsonPath("$.data.series[0].paidContentCount").value(nullValue())) + .andExpect(jsonPath("$.data.series[0].purchasedPaidContentRate").value(nullValue())) + } + + private fun createFixture(prefix: String): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.now() + val viewer = saveMember("$prefix-viewer", MemberRole.USER) + val creator = saveMember("$prefix-creator", MemberRole.CREATOR) + val theme = saveTheme("$prefix-theme") + val series = saveSeries("$prefix-series", creator, "$prefix-cover.png") + val purchasedPaid = saveAudioContent(creator, theme, now.minusHours(2), price = 300) + val unpurchasedPaid = saveAudioContent(creator, theme, now.minusHours(1), price = 200) + saveSeriesContent(series, purchasedPaid) + saveSeriesContent(series, unpurchasedPaid) + saveOrder(viewer, creator, purchasedPaid, OrderType.KEEP) + entityManager.flush() + + Fixture( + viewer = viewer, + creator = creator, + creatorId = creator.id!!, + 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 saveSeries(title: String, creator: Member, coverImage: String): Series { + val series = Series( + title = title, + introduction = "introduction", + languageCode = "ko", + state = SeriesState.PROCEEDING, + isAdult = false, + isOriginal = true + ) + series.member = creator + series.genre = saveSeriesGenre(title) + series.coverImage = coverImage + series.publishedDaysOfWeek.addAll(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU)) + 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 saveAudioContent( + creator: Member, + theme: AudioContentTheme, + releaseDate: LocalDateTime, + price: Int + ): AudioContent { + val content = AudioContent( + title = "audio-$releaseDate", + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate, + price = price, + isAdult = false, + isPointAvailable = true + ) + content.member = creator + content.theme = theme + content.isActive = true + content.coverImage = "audio.png" + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = content + entityManager.persist(seriesContent) + return seriesContent + } + + private fun saveOrder(member: Member, creator: Member, content: AudioContent, type: OrderType): Order { + val order = Order(type = type, isActive = true) + order.member = member + order.creator = creator + order.audioContent = content + entityManager.persist(order) + return order + } + + private data class Fixture( + val viewer: Member, + val creator: Member, + val creatorId: Long, + val seriesId: Long + ) +}