diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt index bbef9bb9..85bd1114 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt @@ -2,22 +2,23 @@ package kr.co.vividnext.sodalive.v2.api.home.following.application import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse +import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryService import org.springframework.stereotype.Component @Component -class HomeFollowingFacade { +class HomeFollowingFacade( + private val homeFollowingQueryService: HomeFollowingQueryService, + private val chatRoomListService: ChatRoomListService +) { fun getFollowingTab(member: Member?): HomeFollowingTabResponse { if (member == null) { return HomeFollowingTabResponse.loginRequired() } - return HomeFollowingTabResponse( - isLoginRequired = false, - followingCreators = emptyList(), - onAirLives = emptyList(), - recentChats = emptyList(), - monthlySchedules = emptyList(), - recentNews = emptyList() - ) + val home = homeFollowingQueryService.findHomeFollowing(member) + val recentChats = chatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10).rooms + + return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats)) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt new file mode 100644 index 00000000..03ab3835 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt @@ -0,0 +1,237 @@ +package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.live.room.LiveRoom +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.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox +import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant +import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +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.boot.test.mock.mockito.MockBean +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 java.time.ZoneOffset +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:home-following-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class HomeFollowingEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @MockBean + private lateinit var countryContext: CountryContext + + @Test + @DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다") + fun shouldReturnLoginRequiredForAnonymous() { + mockMvc.perform(get("/api/v2/home/following")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.isLoginRequired").value(true)) + .andExpect(jsonPath("$.data.followingCreators").isEmpty) + .andExpect(jsonPath("$.data.onAirLives").isEmpty) + .andExpect(jsonPath("$.data.recentChats").isEmpty) + .andExpect(jsonPath("$.data.monthlySchedules").isEmpty) + .andExpect(jsonPath("$.data.recentNews").isEmpty) + } + + @Test + @DisplayName("팔로잉 탭 API는 인증 회원의 팔로잉/On Air/최근 대화/스케줄/최근 소식을 조립해 반환한다") + fun shouldAssembleFollowingTabForMember() { + Mockito.doReturn("US").`when`(countryContext).countryCode + val fixture = createFixture() + + mockMvc.perform(get("/api/v2/home/following").with(user(MemberAdapter(fixture.viewer)))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.isLoginRequired").value(false)) + .andExpect(jsonPath("$.data.followingCreators[0].creatorId").value(fixture.creatorId)) + .andExpect(jsonPath("$.data.followingCreators[0].creatorNickname").value("home-following-creator")) + .andExpect(jsonPath("$.data.onAirLives[0].liveId").value(fixture.liveId)) + .andExpect(jsonPath("$.data.onAirLives[0].title").value("home-following-live")) + .andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.chatRoomId)) + .andExpect(jsonPath("$.data.recentChats[0].chatType").value("DM")) + .andExpect(jsonPath("$.data.recentChats[0].targetName").value("home-following-creator")) + .andExpect(jsonPath("$.data.recentChats[0].lastMessage").value("recent dm")) + .andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}")) + .andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}")) + .andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString())) + .andExpect(jsonPath("$.data.recentNews[0].creatorId").doesNotExist()) + .andExpect(jsonPath("$.data.recentNews[0].ranking").doesNotExist()) + .andExpect(jsonPath("$.data.recentNews[0].rank").value(7)) + .andExpect(jsonPath("$.data.recentNews[1].rank").value(nullValue())) + } + + private fun createFixture(): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.now(ZoneOffset.UTC) + val viewer = saveMember("home-following-viewer", MemberRole.USER) + val creator = saveMember("home-following-creator", MemberRole.CREATOR, profileImage = "creator.png") + saveFollowing(viewer, creator) + val live = saveLiveRoom(creator, now.plusHours(1), channelName = "on-air") + val theme = saveTheme() + val audio = saveAudioContent(creator, theme, now.plusDays(1)) + val oldNews = saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null) + val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7) + val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10)) + entityManager.flush() + entityManager.clear() + + Fixture( + viewer = viewer, + creatorId = creator.id!!, + liveId = live.id!!, + audioId = audio.id!!, + chatRoomId = chatRoom.id!!, + rankedNewsId = rankedNews.id!!, + oldNewsId = oldNews.id!! + ) + }!! + } + + private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null): Member { + val member = Member( + email = "$seed@test.com", + password = "password", + nickname = seed, + profileImage = profileImage, + role = role, + countryCode = "US" + ) + entityManager.persist(member) + return member + } + + private fun saveFollowing(member: Member, creator: Member): CreatorFollowing { + val following = CreatorFollowing(isActive = true).apply { + this.member = member + this.creator = creator + } + entityManager.persist(following) + return following + } + + private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom { + val liveRoom = LiveRoom( + title = "home-following-live", + notice = "notice", + beginDateTime = beginDateTime, + numberOfPeople = 0, + isAdult = false + ).apply { + member = creator + this.channelName = channelName + } + entityManager.persist(liveRoom) + return liveRoom + } + + private fun saveTheme(): AudioContentTheme { + val theme = AudioContentTheme(theme = "home-following-theme", image = "theme.png", isActive = true) + entityManager.persist(theme) + return theme + } + + private fun saveAudioContent(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent { + val audio = AudioContent( + title = "home-following-audio", + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate + ).apply { + member = creator + this.theme = theme + duration = "00:10:00" + isActive = true + } + entityManager.persist(audio) + return audio + } + + private fun saveNews( + memberId: Long, + creatorId: Long, + sourceKey: String, + visibleFromAtUtc: LocalDateTime, + rank: Int? + ): HomeFollowingNewsInbox { + val news = HomeFollowingNewsInbox( + memberId = memberId, + creatorId = creatorId, + newsType = FollowingNewsType.CREATOR_RANKING, + sourceKey = sourceKey, + targetId = creatorId, + occurredAtUtc = visibleFromAtUtc.minusMinutes(30), + visibleFromAtUtc = visibleFromAtUtc, + creatorNickname = "home-following-creator", + creatorProfileImagePath = "creator.png", + title = "news-$sourceKey", + body = "news body", + thumbnailImagePath = null, + rank = rank, + isAdult = false + ) + entityManager.persist(news) + return news + } + + private fun saveDmChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): UserCreatorChatRoom { + val room = UserCreatorChatRoom() + entityManager.persist(room) + val viewerParticipant = UserCreatorChatParticipant(room, viewer) + val creatorParticipant = UserCreatorChatParticipant(room, creator) + entityManager.persist(viewerParticipant) + entityManager.persist(creatorParticipant) + val message = UserCreatorChatMessage( + chatRoom = room, + participant = creatorParticipant, + messageType = UserCreatorChatMessageType.TEXT, + textMessage = "recent dm" + ) + entityManager.persist(message) + entityManager.flush() + message.createdAt = messageCreatedAt + message.updatedAt = messageCreatedAt + return room + } + + private data class Fixture( + val viewer: Member, + val creatorId: Long, + val liveId: Long, + val audioId: Long, + val chatRoomId: Long, + val rankedNewsId: Long, + val oldNewsId: Long + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt new file mode 100644 index 00000000..64d97406 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt @@ -0,0 +1,125 @@ +package kr.co.vividnext.sodalive.v2.api.home.following.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse +import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryService +import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +class HomeFollowingFacadeTest { + private val queryService = Mockito.mock(HomeFollowingQueryService::class.java) + private val chatRoomListService = Mockito.mock(ChatRoomListService::class.java) + private val facade = HomeFollowingFacade(queryService, chatRoomListService) + + @Test + @DisplayName("비로그인 회원은 로그인 필요 응답을 반환하고 조회/채팅 서비스를 호출하지 않는다") + fun shouldReturnLoginRequiredWithoutCallingServicesForAnonymous() { + val response = facade.getFollowingTab(null) + + assertTrue(response.isLoginRequired) + assertTrue(response.followingCreators.isEmpty()) + assertTrue(response.onAirLives.isEmpty()) + assertTrue(response.recentChats.isEmpty()) + assertTrue(response.monthlySchedules.isEmpty()) + assertTrue(response.recentNews.isEmpty()) + Mockito.verifyNoInteractions(queryService, chatRoomListService) + } + + @Test + @DisplayName("로그인 회원은 팔로잉 홈 조회 결과에 최근 대화 10개를 조립해 반환한다") + fun shouldAssembleFollowingHomeWithRecentChatsForMember() { + val member = Member( + email = "viewer@test.com", + password = "password", + nickname = "viewer", + role = MemberRole.USER + ).apply { id = 10L } + val home = homeFollowing() + val recentChat = ChatRoomListItemResponse( + roomId = 30L, + chatType = "DM", + targetName = "creator", + targetImageUrl = "https://cdn.test/creator.png", + lastMessage = "hello", + lastMessageAt = "2026-06-25T01:00:00Z" + ) + Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member) + Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null)) + .`when`(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10) + + val response = facade.getFollowingTab(member) + + assertFalse(response.isLoginRequired) + assertEquals(1L, response.followingCreators.single().creatorId) + assertEquals(2L, response.onAirLives.single().liveId) + assertEquals(listOf(recentChat), response.recentChats) + assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId) + assertEquals("news-5", response.recentNews.single().newsId) + Mockito.verify(queryService).findHomeFollowing(member) + Mockito.verify(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10) + } + + private fun homeFollowing(): HomeFollowing { + return HomeFollowing( + followingCreators = listOf( + HomeFollowingCreator( + creatorId = 1L, + creatorNickname = "creator", + creatorProfileImageUrl = "https://cdn.test/creator.png" + ) + ), + onAirLives = listOf( + HomeFollowingLive( + liveId = 2L, + creatorProfileImageUrl = "https://cdn.test/live.png", + creatorNickname = "creator", + title = "live", + startedAtUtc = "2026-06-25T00:00:00Z" + ) + ), + recentChats = emptyList(), + monthlySchedules = listOf( + HomeFollowingSchedule( + scheduleId = "LIVE:4", + creatorId = 1L, + creatorProfileImageUrl = "https://cdn.test/creator.png", + creatorNickname = "creator", + title = "schedule", + type = CreatorActivityType.LIVE, + targetId = 4L, + scheduledAtUtc = "2026-06-25T02:00:00Z", + isOnAir = false + ) + ), + recentNews = listOf( + HomeFollowingNews( + newsId = "news-5", + type = FollowingNewsType.CREATOR_RANKING, + creatorProfileImageUrl = "https://cdn.test/news.png", + creatorNickname = "creator", + title = "news", + body = "body", + thumbnailImageUrl = null, + targetId = 1L, + occurredAtUtc = "2026-06-25T03:00:00Z", + visibleFromAtUtc = "2026-06-25T04:00:00Z", + rank = 7 + ) + ) + ) + } +}