feat(home-following): 팔로잉 탭 facade를 통합한다
This commit is contained in:
@@ -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()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user