feat(home-following): 팔로잉 탭 facade를 통합한다

This commit is contained in:
2026-06-26 02:51:19 +09:00
parent 59439df33e
commit 75bd0ced28
3 changed files with 372 additions and 9 deletions

View File

@@ -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.member.Member
import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse 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 import org.springframework.stereotype.Component
@Component @Component
class HomeFollowingFacade { class HomeFollowingFacade(
private val homeFollowingQueryService: HomeFollowingQueryService,
private val chatRoomListService: ChatRoomListService
) {
fun getFollowingTab(member: Member?): HomeFollowingTabResponse { fun getFollowingTab(member: Member?): HomeFollowingTabResponse {
if (member == null) { if (member == null) {
return HomeFollowingTabResponse.loginRequired() return HomeFollowingTabResponse.loginRequired()
} }
return HomeFollowingTabResponse( val home = homeFollowingQueryService.findHomeFollowing(member)
isLoginRequired = false, val recentChats = chatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10).rooms
followingCreators = emptyList(),
onAirLives = emptyList(), return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats))
recentChats = emptyList(),
monthlySchedules = emptyList(),
recentNews = emptyList()
)
} }
} }

View File

@@ -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
)
}

View File

@@ -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
)
)
)
}
}