feat(creator-channel): 커뮤니티 상세 상태 관리를 추가한다
This commit is contained in:
@@ -0,0 +1,447 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
|
||||||
|
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
|
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||||
|
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||||
|
|
||||||
|
class CreatorChannelCommunityDetailViewModel(
|
||||||
|
private val repository: CreatorChannelRepository,
|
||||||
|
private val legacyRepository: CreatorCommunityRepository,
|
||||||
|
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
private val _detailStateLiveData = MutableLiveData<CreatorChannelCommunityDetailUiState>()
|
||||||
|
val detailStateLiveData: LiveData<CreatorChannelCommunityDetailUiState>
|
||||||
|
get() = _detailStateLiveData
|
||||||
|
|
||||||
|
private val _commentWrittenEventLiveData = MutableLiveData<Boolean>()
|
||||||
|
val commentWrittenEventLiveData: LiveData<Boolean>
|
||||||
|
get() = _commentWrittenEventLiveData
|
||||||
|
|
||||||
|
private val _postChangedEventLiveData = MutableLiveData<Boolean>()
|
||||||
|
val postChangedEventLiveData: LiveData<Boolean>
|
||||||
|
get() = _postChangedEventLiveData
|
||||||
|
|
||||||
|
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
|
||||||
|
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
|
||||||
|
get() = _toastLiveData
|
||||||
|
|
||||||
|
private var postId: Long = 0L
|
||||||
|
private var detailRequestGeneration = 0
|
||||||
|
private var commentRequestGeneration = 0
|
||||||
|
private var isLoadingComments = false
|
||||||
|
private var isTogglingLike = false
|
||||||
|
private var isSendingComment = false
|
||||||
|
private var isModifyingComment = false
|
||||||
|
|
||||||
|
fun loadDetail(postId: Long) {
|
||||||
|
if (postId <= 0) return
|
||||||
|
this.postId = postId
|
||||||
|
val generation = ++detailRequestGeneration
|
||||||
|
commentRequestGeneration++
|
||||||
|
isLoadingComments = false
|
||||||
|
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Loading
|
||||||
|
compositeDisposable.add(
|
||||||
|
repository.getCommunityPostDetail(postId, authToken())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response ->
|
||||||
|
if (generation == detailRequestGeneration) {
|
||||||
|
handleDetailResponse(response)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
if (generation == detailRequestGeneration) {
|
||||||
|
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(it.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMoreComments() {
|
||||||
|
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
|
||||||
|
if (!content.isCommentAvailable || !content.hasNextComment || isLoadingComments) return
|
||||||
|
|
||||||
|
loadComments(page = content.commentPage + 1, append = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleLike() {
|
||||||
|
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
|
||||||
|
if (isTogglingLike) return
|
||||||
|
|
||||||
|
val previousPost = content.post
|
||||||
|
val nextPost = previousPost.copy(
|
||||||
|
isLiked = !previousPost.isLiked,
|
||||||
|
likeCount = (previousPost.likeCount + if (previousPost.isLiked) -1 else 1).coerceAtLeast(0)
|
||||||
|
)
|
||||||
|
isTogglingLike = true
|
||||||
|
_detailStateLiveData.value = content.copy(post = nextPost)
|
||||||
|
compositeDisposable.add(
|
||||||
|
legacyRepository.communityPostLike(postId, authToken())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response ->
|
||||||
|
isTogglingLike = false
|
||||||
|
if (response.success) {
|
||||||
|
_postChangedEventLiveData.value = true
|
||||||
|
} else {
|
||||||
|
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
|
||||||
|
?: return@subscribe
|
||||||
|
_detailStateLiveData.value = current.copy(post = previousPost)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isTogglingLike = false
|
||||||
|
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
|
||||||
|
?: return@subscribe
|
||||||
|
_detailStateLiveData.value = current.copy(post = previousPost)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCommentInput(input: String) {
|
||||||
|
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
|
||||||
|
if (!content.isCommentAvailable) return
|
||||||
|
|
||||||
|
_detailStateLiveData.value = content.copy(commentInput = input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitComment() {
|
||||||
|
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
|
||||||
|
val comment = content.commentInput.trim()
|
||||||
|
if (!content.isCommentAvailable || comment.isBlank() || isSendingComment) return
|
||||||
|
|
||||||
|
isSendingComment = true
|
||||||
|
_detailStateLiveData.value = content.copy(isSendingComment = true)
|
||||||
|
compositeDisposable.add(
|
||||||
|
legacyRepository.registerComment(
|
||||||
|
postId = postId,
|
||||||
|
comment = comment,
|
||||||
|
parentId = null,
|
||||||
|
isSecret = false,
|
||||||
|
token = authToken()
|
||||||
|
)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response ->
|
||||||
|
isSendingComment = false
|
||||||
|
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
|
||||||
|
?: return@subscribe
|
||||||
|
if (response.success) {
|
||||||
|
_commentWrittenEventLiveData.value = true
|
||||||
|
_detailStateLiveData.value = current.copy(commentInput = "", isSendingComment = false)
|
||||||
|
loadDetail(postId)
|
||||||
|
} else {
|
||||||
|
showMutationFailureToast(response.message)
|
||||||
|
_detailStateLiveData.value = current.copy(isSendingComment = false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isSendingComment = false
|
||||||
|
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
|
||||||
|
?: return@subscribe
|
||||||
|
showMutationFailureToast()
|
||||||
|
_detailStateLiveData.value = current.copy(isSendingComment = false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeCommentWrittenEvent() {
|
||||||
|
_commentWrittenEventLiveData.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumePostChangedEvent() {
|
||||||
|
_postChangedEventLiveData.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modifyComment(commentId: Long, comment: String) {
|
||||||
|
val trimmedComment = comment.trim()
|
||||||
|
if (commentId <= 0 || isModifyingComment) return
|
||||||
|
if (trimmedComment.isBlank()) {
|
||||||
|
showBlankCommentToast()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modifyComment(ModifyCommentRequest(commentId = commentId, comment = trimmedComment))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteComment(commentId: Long) {
|
||||||
|
if (commentId <= 0 || isModifyingComment) return
|
||||||
|
|
||||||
|
modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDetailResponse(response: ApiResponse<CreatorChannelCommunityPostDetailResponse>) {
|
||||||
|
val data = response.data
|
||||||
|
if (!response.success || data == null) {
|
||||||
|
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(response.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isCreatorOwner = data.creatorId == SharedPreferenceManager.userId
|
||||||
|
val comments = if (data.isCommentAvailable) {
|
||||||
|
data.comments.comments.map { it.toUiModel(isCreatorOwner) }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Content(
|
||||||
|
post = data.toUiModel(),
|
||||||
|
isCommentAvailable = data.isCommentAvailable,
|
||||||
|
isCommentInputVisible = data.isCommentAvailable,
|
||||||
|
comments = comments,
|
||||||
|
commentPage = data.comments.page,
|
||||||
|
hasNextComment = data.isCommentAvailable && data.comments.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadComments(page: Int, append: Boolean) {
|
||||||
|
if (postId <= 0 || isLoadingComments && append) return
|
||||||
|
val generation = if (append) {
|
||||||
|
commentRequestGeneration
|
||||||
|
} else {
|
||||||
|
++commentRequestGeneration
|
||||||
|
}
|
||||||
|
isLoadingComments = true
|
||||||
|
val beforeRequest = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
|
||||||
|
if (beforeRequest != null) {
|
||||||
|
_detailStateLiveData.value = beforeRequest.copy(isLoadingComments = true)
|
||||||
|
}
|
||||||
|
compositeDisposable.add(
|
||||||
|
repository.getCommunityPostComments(postId, page, DEFAULT_PAGE_SIZE, authToken())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response -> handleCommentsResponse(response, append, generation) },
|
||||||
|
{
|
||||||
|
if (generation != commentRequestGeneration) return@subscribe
|
||||||
|
|
||||||
|
isLoadingComments = false
|
||||||
|
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
|
||||||
|
?: return@subscribe
|
||||||
|
_detailStateLiveData.value = current.copy(isLoadingComments = false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCommentsResponse(
|
||||||
|
response: ApiResponse<CreatorChannelCommunityCommentsResponse>,
|
||||||
|
append: Boolean,
|
||||||
|
generation: Int
|
||||||
|
) {
|
||||||
|
if (generation != commentRequestGeneration) return
|
||||||
|
|
||||||
|
isLoadingComments = false
|
||||||
|
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
|
||||||
|
val data = response.data
|
||||||
|
if (!response.success || data == null) {
|
||||||
|
_detailStateLiveData.value = current.copy(isLoadingComments = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isCreatorOwner = current.post.creatorId == SharedPreferenceManager.userId
|
||||||
|
val nextComments = data.comments.map { it.toUiModel(isCreatorOwner) }
|
||||||
|
_detailStateLiveData.value = current.copy(
|
||||||
|
post = current.post.copy(commentCount = data.commentCount),
|
||||||
|
comments = if (append) current.comments + nextComments else nextComments,
|
||||||
|
commentPage = data.page,
|
||||||
|
hasNextComment = data.hasNext,
|
||||||
|
isLoadingComments = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modifyComment(request: ModifyCommentRequest) {
|
||||||
|
if (postId <= 0) return
|
||||||
|
|
||||||
|
isModifyingComment = true
|
||||||
|
compositeDisposable.add(
|
||||||
|
legacyRepository.modifyComment(request, authToken())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{ response ->
|
||||||
|
isModifyingComment = false
|
||||||
|
if (response.success) {
|
||||||
|
_postChangedEventLiveData.value = true
|
||||||
|
loadDetail(postId)
|
||||||
|
} else {
|
||||||
|
showMutationFailureToast(response.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isModifyingComment = false
|
||||||
|
showMutationFailureToast()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMutationFailureToast(message: String? = null) {
|
||||||
|
_toastLiveData.value = CreatorChannelEvent(
|
||||||
|
if (message.isNullOrBlank()) {
|
||||||
|
ToastMessage(resId = R.string.common_error_unknown)
|
||||||
|
} else {
|
||||||
|
ToastMessage(message = message)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBlankCommentToast() {
|
||||||
|
_toastLiveData.value = CreatorChannelEvent(
|
||||||
|
ToastMessage(resId = R.string.screen_creator_community_write_content_hint)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorChannelCommunityPostDetailResponse.toUiModel() = CreatorChannelCommunityPostDetailUiModel(
|
||||||
|
postId = postId,
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
creatorProfileUrl = creatorProfileUrl,
|
||||||
|
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
|
||||||
|
content = content,
|
||||||
|
imageUrl = imageUrl.takeUnless { isLocked() },
|
||||||
|
audioUrl = audioUrl.takeUnless { isLocked() },
|
||||||
|
price = price,
|
||||||
|
existOrdered = existOrdered,
|
||||||
|
likeCount = likeCount,
|
||||||
|
commentCount = commentCount,
|
||||||
|
isLiked = isLiked,
|
||||||
|
isPinned = isPinned,
|
||||||
|
isLocked = isLocked(),
|
||||||
|
showPaywall = isLocked()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelCommunityPostDetailResponse.isLocked(): Boolean {
|
||||||
|
return price > 0 && !existOrdered && creatorId != SharedPreferenceManager.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorChannelCommunityCommentResponse.toUiModel(
|
||||||
|
isCreatorOwner: Boolean
|
||||||
|
) = CreatorChannelCommunityCommentUiModel(
|
||||||
|
commentId = commentId,
|
||||||
|
memberId = writerId,
|
||||||
|
memberNickname = writerNickname,
|
||||||
|
memberProfileUrl = writerProfileImageUrl,
|
||||||
|
createdAtUtc = createdAtUtc,
|
||||||
|
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
|
||||||
|
comment = content,
|
||||||
|
likeCount = 0,
|
||||||
|
replyCount = if (latestReply != null) 1 else 0,
|
||||||
|
isLiked = false,
|
||||||
|
isMine = writerId == SharedPreferenceManager.userId,
|
||||||
|
isCreatorOwner = isCreatorOwner,
|
||||||
|
latestReply = latestReply?.toUiModel(parentCommentId = commentId, isCreatorOwner = isCreatorOwner)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelCommunityReplyResponse.toUiModel(
|
||||||
|
parentCommentId: Long,
|
||||||
|
isCreatorOwner: Boolean
|
||||||
|
) = CreatorChannelCommunityReplyUiModel(
|
||||||
|
replyId = commentId,
|
||||||
|
commentId = parentCommentId,
|
||||||
|
memberId = writerId,
|
||||||
|
memberNickname = writerNickname,
|
||||||
|
memberProfileUrl = writerProfileImageUrl,
|
||||||
|
createdAtUtc = createdAtUtc,
|
||||||
|
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
|
||||||
|
comment = content,
|
||||||
|
likeCount = 0,
|
||||||
|
isLiked = false,
|
||||||
|
isMine = writerId == SharedPreferenceManager.userId,
|
||||||
|
isCreatorOwner = isCreatorOwner
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_PAGE_SIZE = 20
|
||||||
|
private const val FIRST_PAGE = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CreatorChannelCommunityDetailUiState {
|
||||||
|
data object Loading : CreatorChannelCommunityDetailUiState
|
||||||
|
data class Error(val message: String?) : CreatorChannelCommunityDetailUiState
|
||||||
|
data class Content(
|
||||||
|
val post: CreatorChannelCommunityPostDetailUiModel,
|
||||||
|
val isCommentAvailable: Boolean,
|
||||||
|
val isCommentInputVisible: Boolean,
|
||||||
|
val comments: List<CreatorChannelCommunityCommentUiModel>,
|
||||||
|
val commentPage: Int,
|
||||||
|
val hasNextComment: Boolean,
|
||||||
|
val isLoadingComments: Boolean = false,
|
||||||
|
val commentInput: String = "",
|
||||||
|
val isSendingComment: Boolean = false
|
||||||
|
) : CreatorChannelCommunityDetailUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityPostDetailUiModel(
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileUrl: String?,
|
||||||
|
val createdAtText: String,
|
||||||
|
val content: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val audioUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
val existOrdered: Boolean,
|
||||||
|
val likeCount: Int,
|
||||||
|
val commentCount: Int,
|
||||||
|
val isLiked: Boolean,
|
||||||
|
val isPinned: Boolean,
|
||||||
|
val isLocked: Boolean,
|
||||||
|
val showPaywall: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentUiModel(
|
||||||
|
val commentId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val memberNickname: String,
|
||||||
|
val memberProfileUrl: String?,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val createdAtText: String,
|
||||||
|
val comment: String,
|
||||||
|
val likeCount: Int,
|
||||||
|
val replyCount: Int,
|
||||||
|
val isLiked: Boolean,
|
||||||
|
val isMine: Boolean,
|
||||||
|
val isCreatorOwner: Boolean,
|
||||||
|
val latestReply: CreatorChannelCommunityReplyUiModel?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityReplyUiModel(
|
||||||
|
val replyId: Long,
|
||||||
|
val commentId: Long,
|
||||||
|
val memberId: Long,
|
||||||
|
val memberNickname: String,
|
||||||
|
val memberProfileUrl: String?,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val createdAtText: String,
|
||||||
|
val comment: String,
|
||||||
|
val likeCount: Int,
|
||||||
|
val isLiked: Boolean,
|
||||||
|
val isMine: Boolean,
|
||||||
|
val isCreatorOwner: Boolean
|
||||||
|
)
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.community
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
|
||||||
|
import io.reactivex.rxjava3.core.Scheduler
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
|
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
|
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||||
|
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailUiState
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailViewModel
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mockito.kotlin.any
|
||||||
|
import org.mockito.kotlin.argThat
|
||||||
|
import org.mockito.kotlin.never
|
||||||
|
import org.mockito.kotlin.times
|
||||||
|
import org.mockito.kotlin.verify
|
||||||
|
import org.mockito.kotlin.whenever
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [28], application = Application::class)
|
||||||
|
class CreatorChannelCommunityDetailViewModelTest {
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
private lateinit var repository: CreatorChannelRepository
|
||||||
|
private lateinit var legacyRepository: CreatorCommunityRepository
|
||||||
|
private lateinit var viewModel: CreatorChannelCommunityDetailViewModel
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
setImmediateRxSchedulers()
|
||||||
|
SharedPreferenceManager.resetForTest()
|
||||||
|
SharedPreferenceManager.init(context)
|
||||||
|
SharedPreferenceManager.token = "test-token"
|
||||||
|
SharedPreferenceManager.userId = 10L
|
||||||
|
repository = org.mockito.kotlin.mock()
|
||||||
|
legacyRepository = org.mockito.kotlin.mock()
|
||||||
|
viewModel = CreatorChannelCommunityDetailViewModel(repository, legacyRepository, testFormatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
RxJavaPlugins.reset()
|
||||||
|
RxAndroidPlugins.reset()
|
||||||
|
SharedPreferenceManager.resetForTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `상세 로드는 본문 미디어 좋아요 댓글 상태를 매핑하고 댓글 첫 페이지를 조회한다`() {
|
||||||
|
stubDetail(
|
||||||
|
detailResponse(
|
||||||
|
isCommentAvailable = true,
|
||||||
|
isLiked = true,
|
||||||
|
likeCount = 3,
|
||||||
|
commentIds = listOf(11L, 12L),
|
||||||
|
commentsHasNext = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertEquals(POST_ID, state.post.postId)
|
||||||
|
assertEquals("body", state.post.content)
|
||||||
|
assertEquals("image.png", state.post.imageUrl)
|
||||||
|
assertEquals("audio.mp3", state.post.audioUrl)
|
||||||
|
assertTrue(state.post.isLiked)
|
||||||
|
assertEquals(3, state.post.likeCount)
|
||||||
|
assertTrue(state.isCommentAvailable)
|
||||||
|
assertEquals(listOf(11L, 12L), state.comments.map { it.commentId })
|
||||||
|
assertEquals(0, state.commentPage)
|
||||||
|
assertTrue(state.hasNextComment)
|
||||||
|
verify(repository).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
|
||||||
|
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `채널 작성자는 상세 embedded 타인 댓글의 삭제 메뉴 권한을 가진다`() {
|
||||||
|
SharedPreferenceManager.userId = 100L
|
||||||
|
stubDetail(
|
||||||
|
detailResponse(
|
||||||
|
isCommentAvailable = true,
|
||||||
|
creatorId = 100L,
|
||||||
|
commentIds = listOf(11L)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertFalse(state.comments.first().isMine)
|
||||||
|
assertTrue(state.comments.first().isCreatorOwner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `댓글 페이지네이션은 hasNext일 때 다음 페이지를 append하고 중복 loadMore를 막는다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L), commentsHasNext = true))
|
||||||
|
whenever(
|
||||||
|
repository.getCommunityPostComments(
|
||||||
|
POST_ID,
|
||||||
|
1,
|
||||||
|
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
|
||||||
|
AUTH_TOKEN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.thenReturn(Single.just(ApiResponse(true, commentsResponse(page = 1, ids = listOf(12L), hasNext = false), null)))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.loadMoreComments()
|
||||||
|
viewModel.loadMoreComments()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertEquals(listOf(11L, 12L), state.comments.map { it.commentId })
|
||||||
|
assertEquals(1, state.commentPage)
|
||||||
|
assertFalse(state.hasNextComment)
|
||||||
|
verify(repository, times(1)).getCommunityPostComments(
|
||||||
|
POST_ID,
|
||||||
|
1,
|
||||||
|
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
|
||||||
|
AUTH_TOKEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `좋아요 토글은 낙관적으로 상태를 바꾸고 진행 중 중복 호출을 막는다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
|
||||||
|
val likeSubject = PublishSubject.create<ApiResponse<Any>>()
|
||||||
|
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN)).thenReturn(likeSubject.firstOrError())
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.toggleLike()
|
||||||
|
viewModel.toggleLike()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertTrue(state.post.isLiked)
|
||||||
|
assertEquals(4, state.post.likeCount)
|
||||||
|
verify(legacyRepository, times(1)).communityPostLike(POST_ID, AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `좋아요 토글 실패는 이전 좋아요 상태와 개수로 롤백한다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
|
||||||
|
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN)).thenReturn(Single.error(RuntimeException("fail")))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.toggleLike()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertFalse(state.post.isLiked)
|
||||||
|
assertEquals(3, state.post.likeCount)
|
||||||
|
verify(legacyRepository).communityPostLike(POST_ID, AUTH_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `좋아요 토글 success false 응답은 이전 좋아요 상태와 개수로 롤백한다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
|
||||||
|
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(Single.just(ApiResponse(false, Any(), null)))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.toggleLike()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertFalse(state.post.isLiked)
|
||||||
|
assertEquals(3, state.post.likeCount)
|
||||||
|
assertFalse(viewModel.postChangedEventLiveData.requireValue() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `좋아요 토글 success true 응답은 게시물 변경 이벤트를 노출한다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
|
||||||
|
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.toggleLike()
|
||||||
|
|
||||||
|
assertTrue(viewModel.postChangedEventLiveData.requireValue() == true)
|
||||||
|
viewModel.consumePostChangedEvent()
|
||||||
|
assertFalse(viewModel.postChangedEventLiveData.requireValue() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `댓글 작성은 trim 후 전송하고 성공 시 입력을 비우고 댓글을 새로고침한다`() {
|
||||||
|
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(
|
||||||
|
Single.just(
|
||||||
|
ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(11L)), null)
|
||||||
|
),
|
||||||
|
Single.just(
|
||||||
|
ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(21L)), null)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
whenever(legacyRepository.registerComment(POST_ID, "hello", null, false, AUTH_TOKEN))
|
||||||
|
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.updateCommentInput(" hello ")
|
||||||
|
viewModel.submitComment()
|
||||||
|
viewModel.submitComment()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertEquals("", state.commentInput)
|
||||||
|
assertTrue(viewModel.commentWrittenEventLiveData.requireValue() == true)
|
||||||
|
assertEquals(listOf(21L), state.comments.map { it.commentId })
|
||||||
|
verify(repository, times(2)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
|
||||||
|
verify(legacyRepository, times(1)).registerComment(POST_ID, "hello", null, false, AUTH_TOKEN)
|
||||||
|
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `댓글 작성 실패는 API 메시지 toast 이벤트를 노출하고 입력 상태를 유지한다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = true))
|
||||||
|
stubComments(commentsResponse(ids = listOf(11L), hasNext = false))
|
||||||
|
whenever(legacyRepository.registerComment(POST_ID, "hello", null, false, AUTH_TOKEN))
|
||||||
|
.thenReturn(Single.just(ApiResponse(false, Any(), "작성 실패")))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.updateCommentInput("hello")
|
||||||
|
viewModel.submitComment()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertEquals("hello", state.commentInput)
|
||||||
|
assertFalse(state.isSendingComment)
|
||||||
|
assertEquals("작성 실패", viewModel.toastLiveData.requireValue()?.consume()?.message)
|
||||||
|
assertEquals(null, viewModel.toastLiveData.requireValue()?.consume())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `댓글 수정 실패는 알 수 없는 오류 toast 이벤트를 노출한다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = true))
|
||||||
|
stubComments(commentsResponse(ids = listOf(11L), hasNext = false))
|
||||||
|
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
|
||||||
|
.thenReturn(Single.error(RuntimeException("network")))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.modifyComment(COMMENT_ID, "수정 댓글")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
kr.co.vividnext.sodalive.R.string.common_error_unknown,
|
||||||
|
viewModel.toastLiveData.requireValue()?.consume()?.resId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `댓글 수정 내용이 공백이면 내용 입력 toast를 노출하고 API를 호출하지 않는다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = true))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.modifyComment(COMMENT_ID, " ")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
kr.co.vividnext.sodalive.R.string.screen_creator_community_write_content_hint,
|
||||||
|
viewModel.toastLiveData.requireValue()?.consume()?.resId
|
||||||
|
)
|
||||||
|
verify(legacyRepository, never()).modifyComment(any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `댓글 불가 게시물은 댓글 조회와 작성을 호출하지 않고 입력 숨김 상태를 노출한다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = false))
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
viewModel.updateCommentInput("hello")
|
||||||
|
viewModel.submitComment()
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertFalse(state.isCommentAvailable)
|
||||||
|
assertFalse(state.isCommentInputVisible)
|
||||||
|
assertTrue(state.comments.isEmpty())
|
||||||
|
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
|
||||||
|
verify(legacyRepository, never()).registerComment(any(), any(), any(), any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `유료 미구매 게시물은 작성자가 아니면 이미지와 오디오 URL을 노출하지 않는다`() {
|
||||||
|
SharedPreferenceManager.userId = 10L
|
||||||
|
stubDetail(
|
||||||
|
detailResponse(
|
||||||
|
isCommentAvailable = false,
|
||||||
|
price = 300,
|
||||||
|
existOrdered = false,
|
||||||
|
creatorId = 999L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertTrue(state.post.isLocked)
|
||||||
|
assertEquals(null, state.post.imageUrl)
|
||||||
|
assertEquals(null, state.post.audioUrl)
|
||||||
|
assertTrue(state.post.showPaywall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `채널 작성자는 유료 미구매 게시물도 이미지와 오디오 URL을 볼 수 있다`() {
|
||||||
|
SharedPreferenceManager.userId = 999L
|
||||||
|
stubDetail(
|
||||||
|
detailResponse(
|
||||||
|
isCommentAvailable = false,
|
||||||
|
price = 300,
|
||||||
|
existOrdered = false,
|
||||||
|
creatorId = 999L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertFalse(state.post.isLocked)
|
||||||
|
assertEquals("image.png", state.post.imageUrl)
|
||||||
|
assertEquals("audio.mp3", state.post.audioUrl)
|
||||||
|
assertFalse(state.post.showPaywall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `댓글 UI 모델은 답글 화면 재포맷을 위해 원본 createdAtUtc를 보존한다`() {
|
||||||
|
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L)))
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertEquals("2026-07-08T00:00:00Z", state.comments.first().createdAtUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `상세 댓글 수정과 삭제는 legacy modifyComment를 호출하고 성공 시 댓글을 새로고침한다`() {
|
||||||
|
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(
|
||||||
|
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(11L)), null)),
|
||||||
|
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(12L)), null)),
|
||||||
|
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(13L)), null))
|
||||||
|
)
|
||||||
|
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
|
||||||
|
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
|
||||||
|
viewModel.modifyComment(COMMENT_ID, " 수정 댓글 ")
|
||||||
|
viewModel.deleteComment(COMMENT_ID)
|
||||||
|
|
||||||
|
verify(legacyRepository).modifyComment(
|
||||||
|
argThat<ModifyCommentRequest> { commentId == COMMENT_ID && comment == "수정 댓글" && isActive == null },
|
||||||
|
org.mockito.kotlin.eq(AUTH_TOKEN)
|
||||||
|
)
|
||||||
|
verify(legacyRepository).modifyComment(
|
||||||
|
argThat<ModifyCommentRequest> { commentId == COMMENT_ID && comment == null && isActive == false },
|
||||||
|
org.mockito.kotlin.eq(AUTH_TOKEN)
|
||||||
|
)
|
||||||
|
verify(repository, times(3)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
|
||||||
|
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `새 상세 로드 후 이전 댓글 페이지 응답이 도착해도 현재 댓글 목록을 덮지 않는다`() {
|
||||||
|
val loadMoreSubject = PublishSubject.create<ApiResponse<CreatorChannelCommunityCommentsResponse>>()
|
||||||
|
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(
|
||||||
|
Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
detailResponse(isCommentAvailable = true, commentIds = listOf(11L), commentsHasNext = true),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Single.just(
|
||||||
|
ApiResponse(
|
||||||
|
true,
|
||||||
|
detailResponse(isCommentAvailable = true, commentIds = listOf(21L), commentsHasNext = false),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
whenever(
|
||||||
|
repository.getCommunityPostComments(
|
||||||
|
POST_ID,
|
||||||
|
1,
|
||||||
|
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
|
||||||
|
AUTH_TOKEN
|
||||||
|
)
|
||||||
|
).thenReturn(loadMoreSubject.firstOrError())
|
||||||
|
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
viewModel.loadMoreComments()
|
||||||
|
viewModel.loadDetail(POST_ID)
|
||||||
|
loadMoreSubject.onNext(ApiResponse(true, commentsResponse(page = 1, ids = listOf(99L), hasNext = false), null))
|
||||||
|
|
||||||
|
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
|
||||||
|
assertEquals(listOf(21L), state.comments.map { it.commentId })
|
||||||
|
assertEquals(0, state.commentPage)
|
||||||
|
assertFalse(state.hasNextComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stubDetail(response: CreatorChannelCommunityPostDetailResponse) {
|
||||||
|
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
|
||||||
|
.thenReturn(Single.just(ApiResponse(true, response, null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stubComments(response: CreatorChannelCommunityCommentsResponse) {
|
||||||
|
whenever(
|
||||||
|
repository.getCommunityPostComments(
|
||||||
|
POST_ID,
|
||||||
|
0,
|
||||||
|
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
|
||||||
|
AUTH_TOKEN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.thenReturn(Single.just(ApiResponse(true, response, null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detailResponse(
|
||||||
|
isCommentAvailable: Boolean,
|
||||||
|
isLiked: Boolean = false,
|
||||||
|
likeCount: Int = 1,
|
||||||
|
price: Int = 0,
|
||||||
|
existOrdered: Boolean = true,
|
||||||
|
creatorId: Long = 100L,
|
||||||
|
commentIds: List<Long> = emptyList(),
|
||||||
|
commentsHasNext: Boolean = false
|
||||||
|
) = CreatorChannelCommunityPostDetailResponse(
|
||||||
|
postId = POST_ID,
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileUrl = "profile.png",
|
||||||
|
createdAtUtc = "2026-07-08T00:00:00Z",
|
||||||
|
content = "body",
|
||||||
|
imageUrl = "image.png",
|
||||||
|
audioUrl = "audio.mp3",
|
||||||
|
price = price,
|
||||||
|
existOrdered = existOrdered,
|
||||||
|
isCommentAvailable = isCommentAvailable,
|
||||||
|
likeCount = likeCount,
|
||||||
|
commentCount = 2,
|
||||||
|
isLiked = isLiked,
|
||||||
|
isPinned = false,
|
||||||
|
comments = commentsResponse(ids = commentIds, hasNext = commentsHasNext)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun commentsResponse(
|
||||||
|
page: Int = 0,
|
||||||
|
ids: List<Long>,
|
||||||
|
hasNext: Boolean
|
||||||
|
) = CreatorChannelCommunityCommentsResponse(
|
||||||
|
commentCount = ids.size,
|
||||||
|
comments = ids.map { commentResponse(it) },
|
||||||
|
page = page,
|
||||||
|
size = CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
|
||||||
|
hasNext = hasNext
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun commentResponse(id: Long) = CreatorChannelCommunityCommentResponse(
|
||||||
|
commentId = id,
|
||||||
|
writerId = 10L,
|
||||||
|
writerProfileImageUrl = "member.png",
|
||||||
|
writerNickname = "member $id",
|
||||||
|
content = "comment $id",
|
||||||
|
isSecret = false,
|
||||||
|
createdAtUtc = "2026-07-08T00:00:00Z",
|
||||||
|
latestReply = null
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun setImmediateRxSchedulers() {
|
||||||
|
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
|
||||||
|
RxJavaPlugins.setIoSchedulerHandler(trampoline)
|
||||||
|
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||||
|
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> LiveData<T>.requireValue(): T? {
|
||||||
|
var value: T? = null
|
||||||
|
val observer = Observer<T> { value = it }
|
||||||
|
observeForever(observer)
|
||||||
|
removeObserver(observer)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val POST_ID = 200L
|
||||||
|
const val COMMENT_ID = 11L
|
||||||
|
const val AUTH_TOKEN = "Bearer test-token"
|
||||||
|
val testFormatter = UtcRelativeTimeTextFormatter { "방금 전" }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user