diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt new file mode 100644 index 00000000..8bd65e92 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt @@ -0,0 +1,142 @@ +package kr.co.vividnext.sodalive.v2.api.home.following.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +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 + +data class HomeFollowingTabResponse( + @JsonProperty("isLoginRequired") + val isLoginRequired: Boolean, + val followingCreators: List, + val onAirLives: List, + val recentChats: List, + val monthlySchedules: List, + val recentNews: List +) { + companion object { + fun loginRequired(): HomeFollowingTabResponse { + return HomeFollowingTabResponse( + isLoginRequired = true, + followingCreators = emptyList(), + onAirLives = emptyList(), + recentChats = emptyList(), + monthlySchedules = emptyList(), + recentNews = emptyList() + ) + } + + fun from(home: HomeFollowing): HomeFollowingTabResponse { + return HomeFollowingTabResponse( + isLoginRequired = false, + followingCreators = home.followingCreators.map(FollowingCreatorResponse::from), + onAirLives = home.onAirLives.map(FollowingLiveResponse::from), + recentChats = home.recentChats, + monthlySchedules = home.monthlySchedules.map(FollowingScheduleResponse::from), + recentNews = home.recentNews.map(FollowingNewsResponse::from) + ) + } + } +} + +data class FollowingCreatorResponse( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImageUrl: String +) { + companion object { + fun from(creator: HomeFollowingCreator): FollowingCreatorResponse { + return FollowingCreatorResponse( + creatorId = creator.creatorId, + creatorNickname = creator.creatorNickname, + creatorProfileImageUrl = creator.creatorProfileImageUrl + ) + } + } +} + +data class FollowingLiveResponse( + val liveId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val startedAtUtc: String +) { + companion object { + fun from(live: HomeFollowingLive): FollowingLiveResponse { + return FollowingLiveResponse( + liveId = live.liveId, + creatorProfileImageUrl = live.creatorProfileImageUrl, + creatorNickname = live.creatorNickname, + title = live.title, + startedAtUtc = live.startedAtUtc + ) + } + } +} + +data class FollowingScheduleResponse( + val scheduleId: String, + val creatorId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val type: CreatorActivityType, + val targetId: Long, + val scheduledAtUtc: String, + @JsonProperty("isOnAir") + val isOnAir: Boolean +) { + companion object { + fun from(schedule: HomeFollowingSchedule): FollowingScheduleResponse { + return FollowingScheduleResponse( + scheduleId = schedule.scheduleId, + creatorId = schedule.creatorId, + creatorProfileImageUrl = schedule.creatorProfileImageUrl, + creatorNickname = schedule.creatorNickname, + title = schedule.title, + type = schedule.type, + targetId = schedule.targetId, + scheduledAtUtc = schedule.scheduledAtUtc, + isOnAir = schedule.isOnAir + ) + } + } +} + +data class FollowingNewsResponse( + val newsId: String, + val type: FollowingNewsType, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val body: String, + val thumbnailImageUrl: String?, + val targetId: Long, + val occurredAtUtc: String, + val visibleFromAtUtc: String, + val rank: Int? +) { + companion object { + fun from(news: HomeFollowingNews): FollowingNewsResponse { + return FollowingNewsResponse( + newsId = news.newsId, + type = news.type, + creatorProfileImageUrl = news.creatorProfileImageUrl, + creatorNickname = news.creatorNickname, + title = news.title, + body = news.body, + thumbnailImageUrl = news.thumbnailImageUrl, + targetId = news.targetId, + occurredAtUtc = news.occurredAtUtc, + visibleFromAtUtc = news.visibleFromAtUtc, + rank = news.rank + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt new file mode 100644 index 00000000..78069e72 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.v2.home.following.domain + +enum class FollowingNewsType { + CREATOR_RANKING, + CONTENT_RANKING, + COMMUNITY_POST, + AUDIO_CONTENT, + PHOTO_CONTENT +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt new file mode 100644 index 00000000..eefa5a95 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt @@ -0,0 +1,52 @@ +package kr.co.vividnext.sodalive.v2.home.following.domain + +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType + +data class HomeFollowing( + val followingCreators: List, + val onAirLives: List, + val recentChats: List, + val monthlySchedules: List, + val recentNews: List +) + +data class HomeFollowingCreator( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImageUrl: String +) + +data class HomeFollowingLive( + val liveId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val startedAtUtc: String +) + +data class HomeFollowingSchedule( + val scheduleId: String, + val creatorId: Long, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val type: CreatorActivityType, + val targetId: Long, + val scheduledAtUtc: String, + val isOnAir: Boolean +) + +data class HomeFollowingNews( + val newsId: String, + val type: FollowingNewsType, + val creatorProfileImageUrl: String, + val creatorNickname: String, + val title: String, + val body: String, + val thumbnailImageUrl: String?, + val targetId: Long, + val occurredAtUtc: String, + val visibleFromAtUtc: String, + val rank: Int? +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt new file mode 100644 index 00000000..10b7a5b0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt @@ -0,0 +1,114 @@ +package kr.co.vividnext.sodalive.v2.api.home.following.dto + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +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 + +class HomeFollowingTabResponseTest { + private val objectMapper = jacksonObjectMapper() + + @Test + @DisplayName("비로그인 응답은 로그인이 필요하며 모든 섹션을 빈 배열로 반환한다") + fun shouldReturnLoginRequiredResponseWithEmptySections() { + val response = HomeFollowingTabResponse.loginRequired() + val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + + assertTrue(response.isLoginRequired) + assertTrue(response.followingCreators.isEmpty()) + assertTrue(response.onAirLives.isEmpty()) + assertTrue(response.recentChats.isEmpty()) + assertTrue(response.monthlySchedules.isEmpty()) + assertTrue(response.recentNews.isEmpty()) + assertEquals(true, json["isLoginRequired"].asBoolean()) + assertTrue(json["followingCreators"].isArray) + assertTrue(json["onAirLives"].isArray) + assertTrue(json["recentChats"].isArray) + assertTrue(json["monthlySchedules"].isArray) + assertTrue(json["recentNews"].isArray) + } + + @Test + @DisplayName("팔로잉 탭 도메인은 creatorId 없는 최근 소식과 nullable rank 응답으로 변환한다") + fun shouldMapDomainToResponseWithoutCreatorIdInRecentNews() { + val response = HomeFollowingTabResponse.from(createHomeFollowing()) + val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + + assertFalse(response.isLoginRequired) + assertEquals(1L, response.followingCreators.first().creatorId) + assertEquals(10L, response.onAirLives.first().liveId) + assertEquals(100L, response.recentChats.first().roomId) + assertEquals("LIVE:20", response.monthlySchedules.first().scheduleId) + assertEquals(3, response.recentNews.first().rank) + assertEquals(false, json["isLoginRequired"].asBoolean()) + assertFalse(json["recentNews"][0].has("creatorId")) + assertFalse(json["recentNews"][0].has("ranking")) + assertFalse(json["recentNews"][0].has("rankChange")) + assertFalse(json["recentNews"][0].has("isNew")) + assertEquals(3, json["recentNews"][0]["rank"].asInt()) + assertEquals(true, json["monthlySchedules"][0]["isOnAir"].asBoolean()) + } + + private fun createHomeFollowing(): HomeFollowing { + return HomeFollowing( + followingCreators = listOf(HomeFollowingCreator(1L, "creator", "https://cdn/profile.jpg")), + onAirLives = listOf( + HomeFollowingLive( + liveId = 10L, + creatorProfileImageUrl = "https://cdn/live-profile.jpg", + creatorNickname = "live-creator", + title = "live title", + startedAtUtc = "2026-06-25T00:00:00Z" + ) + ), + recentChats = listOf( + ChatRoomListItemResponse( + roomId = 100L, + chatType = "DM", + targetName = "creator", + targetImageUrl = "https://cdn/chat.jpg", + lastMessage = "hello", + lastMessageAt = "2026-06-25T00:01:00Z" + ) + ), + monthlySchedules = listOf( + HomeFollowingSchedule( + scheduleId = "LIVE:20", + creatorId = 1L, + creatorProfileImageUrl = "https://cdn/schedule.jpg", + creatorNickname = "schedule-creator", + title = "schedule title", + type = CreatorActivityType.LIVE, + targetId = 20L, + scheduledAtUtc = "2026-06-26T00:00:00Z", + isOnAir = true + ) + ), + recentNews = listOf( + HomeFollowingNews( + newsId = "30", + type = FollowingNewsType.CREATOR_RANKING, + creatorProfileImageUrl = "https://cdn/news-profile.jpg", + creatorNickname = "news-creator", + title = "ranking", + body = "ranked", + thumbnailImageUrl = null, + targetId = 1L, + occurredAtUtc = "2026-06-25T00:00:00Z", + visibleFromAtUtc = "2026-06-25T09:00:00Z", + rank = 3 + ) + ) + ) + } +}