From 45fafa9b00cc7ea59aeb28c6f7ed50b827bc0420 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 16:12:04 +0900 Subject: [PATCH] =?UTF-8?q?test(creator-channel):=20FanTalk=20=ED=83=AD=20?= =?UTF-8?q?E2E=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/CreatorChannelFanTalkEndToEndTest.kt | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt new file mode 100644 index 00000000..a09e30b9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt @@ -0,0 +1,182 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web + +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.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-fantalk-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class CreatorChannelFanTalkEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("FanTalk 탭 API는 controller-service-repository를 거쳐 글과 크리에이터 답글을 반환한다") + fun shouldReturnFanTalkTabThroughControllerServiceAndRepository() { + val fixture = createFixture("fantalk-e2e-success") + + mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/fan-talks") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.fanTalkCount").value(2)) + .andExpect(jsonPath("$.data.fanTalks.length()").value(2)) + .andExpect(jsonPath("$.data.fanTalks[0].fanTalkId").value(fixture.newerFanTalkId)) + .andExpect(jsonPath("$.data.fanTalks[0].writerId").value(fixture.writerId)) + .andExpect(jsonPath("$.data.fanTalks[0].writerNickname").value("fan-writer")) + .andExpect(jsonPath("$.data.fanTalks[0].writerProfileImageUrl").value("https://cdn.test/fan-writer.png")) + .andExpect(jsonPath("$.data.fanTalks[0].content").value("newer fan talk")) + .andExpect(jsonPath("$.data.fanTalks[0].createdAtUtc").value("2026-06-22T12:00:00Z")) + .andExpect(jsonPath("$.data.fanTalks[0].creatorReplies.length()").value(1)) + .andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].fanTalkId").value(fixture.creatorReplyId)) + .andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].writerId").value(fixture.creatorId)) + .andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].writerNickname").value("creator")) + .andExpect( + jsonPath("$.data.fanTalks[0].creatorReplies[0].writerProfileImageUrl") + .value("https://cdn.test/creator.png") + ) + .andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].content").value("creator reply")) + .andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].createdAtUtc").value("2026-06-22T12:05:00Z")) + .andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[?(@.fanTalkId == ${fixture.fanReplyId})]").isEmpty) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("FanTalk 탭 API는 page 범위 밖 요청에 빈 목록과 유지된 count를 반환한다") + fun shouldReturnEmptyListAndKeepCountForOutOfRangePage() { + val fixture = createFixture("fantalk-e2e-out-of-range") + + mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/fan-talks") + .param("page", "1") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.fanTalkCount").value(2)) + .andExpect(jsonPath("$.data.fanTalks.length()").value(0)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + private fun createFixture(prefix: String): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.of(2026, 6, 22, 12, 0) + val viewer = saveMember("$prefix-viewer", MemberRole.USER) + val creator = saveMember("$prefix-creator", MemberRole.CREATOR, nickname = "creator", profileImage = "creator.png") + val writer = saveMember("$prefix-writer", MemberRole.USER, nickname = "fan-writer", profileImage = "fan-writer.png") + val newerFanTalk = saveCheers(writer, creator, "newer fan talk", isActive = true, createdAt = now) + saveCheers(writer, creator, "older fan talk", isActive = true, createdAt = now.minusHours(1)) + val creatorReply = saveCheers( + creator, + creator, + "creator reply", + isActive = true, + createdAt = now.plusMinutes(5), + parent = newerFanTalk + ) + val fanReply = saveCheers( + writer, + creator, + "fan reply should be excluded", + isActive = true, + createdAt = now.plusMinutes(10), + parent = newerFanTalk + ) + entityManager.flush() + entityManager.clear() + + Fixture( + viewer = viewer, + creatorId = creator.id!!, + writerId = writer.id!!, + newerFanTalkId = newerFanTalk.id!!, + creatorReplyId = creatorReply.id!!, + fanReplyId = fanReply.id!! + ) + }!! + } + + private fun saveMember( + emailPrefix: String, + role: MemberRole, + nickname: String = emailPrefix, + profileImage: String? = "$emailPrefix.png" + ): Member { + val member = Member( + email = "$emailPrefix@test.com", + password = "password", + nickname = nickname, + profileImage = profileImage, + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveCheers( + member: Member, + creator: Member, + cheers: String, + isActive: Boolean, + createdAt: LocalDateTime, + parent: CreatorCheers? = null + ): CreatorCheers { + val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = isActive) + creatorCheers.member = member + creatorCheers.creator = creator + creatorCheers.parent = parent + entityManager.persist(creatorCheers) + entityManager.flush() + updateCreatedAt(creatorCheers.id!!, createdAt) + return creatorCheers + } + + private fun updateCreatedAt(id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update CreatorCheers e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private data class Fixture( + val viewer: Member, + val creatorId: Long, + val writerId: Long, + val newerFanTalkId: Long, + val creatorReplyId: Long, + val fanReplyId: Long + ) +}