From c04d603ac0e53861626189dc8d4f7be5903437cf Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 9 Jul 2026 00:53:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EC=83=81=EC=84=B8=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelCommunityDetailViewModel.kt | 447 ++++++++++++++++ ...atorChannelCommunityDetailViewModelTest.kt | 504 ++++++++++++++++++ 2 files changed, 951 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailViewModel.kt new file mode 100644 index 00000000..23dc22eb --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/CreatorChannelCommunityDetailViewModel.kt @@ -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() + val detailStateLiveData: LiveData + get() = _detailStateLiveData + + private val _commentWrittenEventLiveData = MutableLiveData() + val commentWrittenEventLiveData: LiveData + get() = _commentWrittenEventLiveData + + private val _postChangedEventLiveData = MutableLiveData() + val postChangedEventLiveData: LiveData + get() = _postChangedEventLiveData + + private val _toastLiveData = MutableLiveData>() + val toastLiveData: LiveData> + 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) { + 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, + 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, + 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 +) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailViewModelTest.kt new file mode 100644 index 00000000..72a618b0 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailViewModelTest.kt @@ -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>() + 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 { commentId == COMMENT_ID && comment == "수정 댓글" && isActive == null }, + org.mockito.kotlin.eq(AUTH_TOKEN) + ) + verify(legacyRepository).modifyComment( + argThat { 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>() + 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 = 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, + 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 LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { 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 { "방금 전" } + } +}