package com.suno.android.ui.screens.hooks import app.cash.turbine.test import arrow.core.Either import com.suno.android.common_analytics.managers.AnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.constants.ReactionType import com.suno.android.common_core_utils.constants.SunoMediaType import com.suno.android.common_core_utils.global_errors.TopLevelErrorManager import com.suno.android.common_core_utils.helpers.AppLifecycleManager import com.suno.android.common_core_utils.helpers.SunoAppLifecycleEvent import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.managers.HookDownloadManager import com.suno.android.common_data.mappers.clips.ClipStatus import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.hooks.HooksFeed import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.metadata.Recommendation import com.suno.android.common_data.repos.HideCreatorContentType import com.suno.android.common_data.repos.HooksRepository import com.suno.android.common_data.repos.ProfilesRepository import com.suno.android.common_data.repos.RecommendationsRepository import com.suno.android.common_data.repos.ShareLinkRepository import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_networking.remote.entities.RemoteHookReactionBody.Action import com.suno.android.common_ui.components.bottom_sheet.SharePlatformConstants import com.suno.android.gating.statsig.FeatureGate import com.suno.android.gating.statsig.StatsigFeatureDataSource import com.suno.android.hooks.HooksFeatureGateManager import com.suno.android.hooks.HooksFeedTab import com.suno.android.media.MediaManager import com.suno.android.media.hooks.HooksFeedPlayerManager import com.suno.android.media.hooks.toRecommendationMetadata import com.suno.android.ui.screens.home.tabs.BottomTabBarManager import com.suno.android.ui.screens.hooks.clip.HookClipPlayerController import com.suno.android.ui.screens.hooks.feed.HooksFeedController import com.suno.android.ui.screens.hooks.feed.HooksFeedEffect import com.suno.android.ui.screens.hooks.feed.HooksFeedEvent import com.suno.android.ui.screens.hooks.feed.HooksFeedSource import com.suno.android.ui.screens.hooks.feed.HooksFeedUiState import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import java.time.LocalDateTime import kotlin.time.Duration.Companion.seconds class HooksFeedControllerTest { private val hooksRepository = mockk() private val profilesRepository = mockk() private val shareLinkRepository = mockk() private val recommendationsRepository = mockk() private val userSessionRepository = mockk(relaxed = true) private val playerManager = mockk(relaxed = true) private val mediaManager = mockk() private val topLevelErrorManager = mockk() private val processorFactory = mockk() private val hookClipPlayerControllerFactory = mockk() private val statsigManager = mockk() private val hooksFeatureGateManager = mockk() private val appLifecycleStateFlow = MutableStateFlow(SunoAppLifecycleEvent.OnAppFirstLaunch) private val appLifecycleManager = mockk() private val tabBarManager = mockk() private val hookDownloadManager = mockk() private lateinit var subject: HooksFeedController @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) coEvery { hooksRepository.getHooksFeed(startIndex = 0) } returns Either.Right(createMockHooksFeed()) coEvery { hooksRepository.incrementShareCount(any(), any()) } returns Either.Right(Unit) coEvery { hooksRepository.getHooksLyrics(any()) } returns Either.Right(emptyMap()) coEvery { shareLinkRepository.getHookShareLink(any(), any(), any()) } returns Either.Right(Url("https://example.com/share")) every { playerManager.prepareMedia(any(), any()) } just Runs every { mediaManager.hasPlayedAnyClip() } returns false every { mediaManager.stopPlaybackAndClearPlaylist() } just Runs every { mediaManager.setIsPlaying(any()) } just Runs coEvery { topLevelErrorManager.broadcastTopLevelDialogError(any()) } just Runs val analyticsManager = mockk(relaxed = true) every { processorFactory.create(any(), any(), any(), any()) } answers { MviProcessorFactory( analyticsManager = analyticsManager, statsigManager = statsigManager, loggerFactory = mockk(relaxed = true), ).create(firstArg(), secondArg(), thirdArg(), arg(3)) } every { statsigManager.checkGate(FeatureGate.HOOKS_CREATE) } returns false every { statsigManager.checkGate(FeatureGate.NEW_LOGGING) } returns false every { statsigManager.checkGate(FeatureGate.REMIX_CLIP) } returns false every { statsigManager.checkGate(FeatureGate.SHOW_SHARE_VIDEO) } returns false every { hooksFeatureGateManager.hooksFeedTab } returns HooksFeedTab.First every { hooksFeatureGateManager.isHooksCreateEnabled } returns false every { hooksFeatureGateManager.isHooksDownloadEnabled } returns false every { hooksFeatureGateManager.isHooksDownloadSelfEnabled } returns false every { appLifecycleManager.appProcessStateFlow() } returns appLifecycleStateFlow subject = HooksFeedController( processorFactory = processorFactory, coroutineScope = CoroutineScope(StandardTestDispatcher()), source = HooksFeedSource.Home, startIndex = 0, hooksRepository = hooksRepository, profilesRepository = profilesRepository, shareLinkRepository = shareLinkRepository, recommendationsRepository = recommendationsRepository, userSessionRepository = userSessionRepository, playerManager = playerManager, mediaManager = mediaManager, topLevelErrorManager = topLevelErrorManager, clipPlayerControllerFactory = hookClipPlayerControllerFactory, appLifecycleManager = appLifecycleManager, hooksFeatureGateManager = hooksFeatureGateManager, statsigManager = statsigManager, bottomTabBarManager = tabBarManager, hookDownloadManager = hookDownloadManager, ) } @After fun tearDown() { Dispatchers.resetMain() } @Test fun `given controller init when not on feed then feed is not eagerly loaded`() = runTest { advanceUntilIdle() coVerify(exactly = 0) { hooksRepository.getHooksFeed(any()) } subject.state.test { val state = expectMostRecentItem() assert(state.hooks.isEmpty()) { "Expected hooks to be empty before OnResume, but found ${state.hooks.size} hooks" } } } @Test fun `given currently liked hook when ToggleLike event sent then calls repository to unlike`() = runTest { val hook = createMockLocalHookData( hookId = Id("hook-123"), currentUserLiked = true, likeCount = 10, ) mockSetHookReactionSuccess() sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) subject.state.test { val state = expectMostRecentItem() assert(state.hooks.isNotEmpty()) { "Hooks should be loaded after OnResume" } } sendEventAndAdvanceUntilIdle(HooksFeedEvent.ToggleLike(hook)) coVerify { hooksRepository.setHookReaction( hookId = Id("hook-123"), action = Action.Unlike, recommendationMetadata = any(), tapType = any(), ) } } @Test fun `given currently not liked hook when ToggleLike event sent then calls repository to like`() = runTest { val hook = createMockLocalHookData( hookId = Id("hook-456"), currentUserLiked = false, likeCount = 5, ) mockSetHookReactionSuccess() sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) subject.state.test { val state = expectMostRecentItem() assert(state.hooks.isNotEmpty()) { "Hooks should be loaded after OnResume" } } sendEventAndAdvanceUntilIdle(HooksFeedEvent.ToggleLike(hook)) coVerify { hooksRepository.setHookReaction( hookId = Id("hook-456"), action = Action.Like, recommendationMetadata = any(), tapType = any(), ) } } @Test fun `given currently following creator when ToggleFollowCreator event sent then calls repository to unfollow`() = runTest { val creatorHandle = UserHandle("creator") val hook = createMockLocalHookData(handle = creatorHandle, currentUserFollowsCreator = true) mockToggleFollowCreatorSuccess() sendEventAndAdvanceUntilIdle(HooksFeedEvent.ToggleFollowCreator(hook)) coVerify { profilesRepository.followArtistProfile( handle = creatorHandle.handle, unfollow = true, recommendationMetadata = any(), ) } } @Test fun `given currently not following creator when ToggleFollowCreator event sent then calls repository to follow`() = runTest { val creatorHandle = UserHandle("creator") val hook = createMockLocalHookData(handle = creatorHandle, currentUserFollowsCreator = false) mockToggleFollowCreatorSuccess() sendEventAndAdvanceUntilIdle(HooksFeedEvent.ToggleFollowCreator(hook)) coVerify { profilesRepository.followArtistProfile( handle = creatorHandle.handle, unfollow = false, recommendationMetadata = any(), ) } } @Test fun `given null user handle when ToggleFollowCreator event sent then does not call repository`() = runTest { val hook = createMockLocalHookData(handle = null, currentUserFollowsCreator = false) sendEventAndAdvanceUntilIdle(HooksFeedEvent.ToggleFollowCreator(hook)) coVerify(exactly = 0) { profilesRepository.followArtistProfile( handle = any(), unfollow = any(), recommendationMetadata = any(), ) } } @Test fun `given share link request when StartShare event sent then calls repository`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-789")) val platform = SharePlatformConstants.Link.Copy sendEventAndAdvanceUntilIdle(HooksFeedEvent.StartShare(hook, platform)) coVerify { shareLinkRepository.getHookShareLink( contentId = Id("hook-789"), platform = "copy-link", recommendationMetadata = any(), ) } } @Test fun `given successful share link when StartShare event sent then calls incrementShareCount`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-share-123")) val platform = SharePlatformConstants.Link.Copy coEvery { hooksRepository.incrementShareCount(any(), any()) } returns Either.Right(Unit) sendEventAndAdvanceUntilIdle(HooksFeedEvent.StartShare(hook, platform)) coVerify { hooksRepository.incrementShareCount( hookId = Id("hook-share-123"), recommendationMetadata = hook.toRecommendationMetadata(), ) } } @Test fun `when ShowShareSheet event sent then showShareSheet is true`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-790")) sendEventAndAdvanceUntilIdle(HooksFeedEvent.ShowShareSheet(hook)) subject.state.test { assert(expectMostRecentItem().showShareSheet) } } @Test fun `when DismissShareSheet event sent then showShareSheet is false`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-791")) sendEventAndAdvanceUntilIdle(HooksFeedEvent.ShowShareSheet(hook)) sendEventAndAdvanceUntilIdle(HooksFeedEvent.DismissShareSheet) subject.state.test { assert(!expectMostRecentItem().showShareSheet) } } @Test fun `given valid user handle when NavigateToCreatorProfile event sent then emits NavigateToProfile effect`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-792")) val userHandle = UserHandle("testuser") subject.effects.test { sendEventAndAdvanceUntilIdle(HooksFeedEvent.NavigateToCreatorProfile(userHandle, hook)) assertEquals(HooksFeedEffect.NavigateToProfile(userHandle), awaitItem()) } } @Test fun `when NavigateToCreatorProfile event sent then pauses with ProfileTapAway reason`() = runTest { sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) // setUp() loads a mock feed with hooks ["hook-1", ...]. Implementation uses lifecycle-based pausing (via // OnPause event), so we have to play along val hook = createMockLocalHookData(hookId = Id("hook-1")) val userHandle = UserHandle("testuser") sendEventAndAdvanceUntilIdle(HooksFeedEvent.NavigateToCreatorProfile(userHandle, hook)) sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnPause) verify { playerManager.pauseMedia( hookId = hook.hookId, reason = HooksFeedPlayerManager.HookPauseReason.ProfileTapAway, ) } } @Test fun `given null user handle when NavigateToCreatorProfile event sent then does not emit effect`() = runTest { subject.effects.test { val hook = createMockLocalHookData(hookId = Id("hook-793")) sendEventAndAdvanceUntilIdle(HooksFeedEvent.NavigateToCreatorProfile(handle = null, hook = hook)) expectNoEvents() } } @Test fun `given valid hook when ReportInappropriate event sent then calls repository and emits success effect`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-report-123")) mockReportInappropriateSuccess() subject.effects.test { sendEventAndAdvanceUntilIdle(HooksFeedEvent.ReportInappropriate(hook)) assertEquals(HooksFeedEffect.ShowSnackbar(HooksFeedEffect.ShowSnackbar.Type.HookReported), awaitItem()) } coVerify { hooksRepository.reportInappropriate( hookId = Id("hook-report-123"), recommendationMetadata = any(), ) } } @Test fun `given repository error when ReportInappropriate event sent then does not emit effect`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-report-456")) mockReportInappropriateError() subject.effects.test { sendEventAndAdvanceUntilIdle(HooksFeedEvent.ReportInappropriate(hook)) expectNoEvents() } coVerify { hooksRepository.reportInappropriate( hookId = Id("hook-report-456"), recommendationMetadata = any(), ) } } @Test fun `when ReportInappropriate event sent then hides bottom sheet`() = runTest { val hook = createMockLocalHookData(hookId = Id("hook-report-789")) mockReportInappropriateSuccess() sendEventAndAdvanceUntilIdle(HooksFeedEvent.ShowMoreMenu(hook)) sendEventAndAdvanceUntilIdle(HooksFeedEvent.ReportInappropriate(hook)) subject.state.test { assertEquals(HooksFeedUiState.BottomSheetState.Hidden, expectMostRecentItem().bottomSheetState) } } @Test fun `given OnAppBackgrounded when observeAppLifecycleEvents then flushes play counts`() = runTest { sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) appLifecycleStateFlow.emit(SunoAppLifecycleEvent.OnAppBackgrounded) advanceUntilIdle() verify(exactly = 1) { playerManager.flushPlayCounts() } } @Test fun `given load more with duplicate hooks when CurrentPageChanged event sent then filters out duplicates`() = runTest { val loadMoreHooks = listOf( createMockLocalHookData(Id("hook-2")), // Duplicate createMockLocalHookData(Id("hook-4")), // New createMockLocalHookData(Id("hook-3")), // Duplicate createMockLocalHookData(Id("hook-5")), // New ) val loadMoreFeed = HooksFeed(hooks = loadMoreHooks) coEvery { hooksRepository.getHooksFeed(startIndex = 3) } returns Either.Right(loadMoreFeed) sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) sendEventAndAdvanceUntilIdle(HooksFeedEvent.CurrentPageChanged(currentPage = 1)) subject.state.test { val state = expectMostRecentItem() assertEquals( listOf("hook-1", "hook-2", "hook-3", "hook-4", "hook-5"), state.hooks.map { it.hookId.value }, ) } } @Test fun `given valid hook with handle when HideCreator event sent then calls repository and emits correct effects`() = runTest { val creatorHandle = UserHandle("creatorToHide") val recommendationItemId = Id("rec-item-456") val hook = createMockLocalHookData( hookId = Id("hook-hide-123"), handle = creatorHandle, recommendationItemId = recommendationItemId, ) coEvery { recommendationsRepository.toggleHideCreator(any(), any(), any(), any()) } returns Either.Right(Unit) sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) sendEventAndAdvanceUntilIdle(HooksFeedEvent.HideCreator(hook)) subject.state.test { assertEquals(HooksFeedUiState.BottomSheetState.Hidden, expectMostRecentItem().bottomSheetState) } coVerify { recommendationsRepository.toggleHideCreator( creatorHandle = creatorHandle, contentType = HideCreatorContentType.Hook, hide = true, recommendationMetadata = any(), ) } } @Test fun `given valid hook when NotInterested event sent then calls repository and emits correct effects`() = runTest { val hook = createMockLocalHookData( hookId = Id("hook-not-interested-123"), ) mockSetHookReactionSuccess() sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) subject.effects.test { sendEventAndAdvanceUntilIdle(HooksFeedEvent.NotInterested(hook)) val effects = mutableListOf() effects.add(awaitItem()) effects.add(awaitItem()) assertEquals(HooksFeedEffect.NavigateToTargetHook(targetIndex = 1), effects[0]) assertEquals(HooksFeedEffect.ShowSnackbar(HooksFeedEffect.ShowSnackbar.Type.NotInterested), effects[1]) cancelAndIgnoreRemainingEvents() } subject.state.test { assertEquals(HooksFeedUiState.BottomSheetState.Hidden, expectMostRecentItem().bottomSheetState) cancelAndIgnoreRemainingEvents() } coVerify { hooksRepository.setHookReaction( hookId = Id("hook-not-interested-123"), action = Action.Dislike, recommendationMetadata = any(), ) } } @Test fun `given same hook when HookFocused event sent then does not pause media`() = runTest { sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) subject.state.test { val state = expectMostRecentItem() assert(state.hooks.isNotEmpty()) { "Hooks should be loaded after OnResume" } } val hook = createMockLocalHookData(Id("hook-1")) sendEventAndAdvanceUntilIdle(HooksFeedEvent.HookFocused(hook, index = 0)) coVerify(exactly = 0) { playerManager.pauseMedia(any(), any()) } verify(atLeast = 1) { playerManager.playMedia( hookId = Id("hook-1"), isMuted = any(), ) } } @Test fun `given different hook when HookFocused event sent then pauses previous and plays new`() = runTest { sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) val hook1 = createMockLocalHookData(Id("hook-1")) val hook2 = createMockLocalHookData(Id("hook-2")) sendEventAndAdvanceUntilIdle(HooksFeedEvent.HookFocused(hook1, index = 0)) sendEventAndAdvanceUntilIdle(HooksFeedEvent.HookFocused(hook2, index = 1)) coVerify { playerManager.pauseMedia( hookId = Id("hook-1"), reason = HooksFeedPlayerManager.HookPauseReason.SwipeAway, ) } verify(atLeast = 1) { playerManager.playMedia( hookId = Id("hook-2"), isMuted = any(), ) } } @Test fun `given hidden creator when HookFocused event sent then does not play media`() = runTest { val creatorHandle = UserHandle("hiddenCreator") val hook = createMockLocalHookData(Id("hook-hidden"), handle = creatorHandle) coEvery { recommendationsRepository.toggleHideCreator(any(), any(), any(), any()) } returns Either.Right(Unit) sendEventAndAdvanceUntilIdle(HooksFeedEvent.OnResume) sendEventAndAdvanceUntilIdle(HooksFeedEvent.HideCreator(hook)) sendEventAndAdvanceUntilIdle(HooksFeedEvent.HookFocused(hook, index = 0)) coVerify(exactly = 0) { playerManager.playMedia( hookId = Id("hook-hidden"), isMuted = any(), ) } } private fun TestScope.sendEventAndAdvanceUntilIdle( event: HooksFeedEvent, ) { subject.sendEvent(event) advanceUntilIdle() } private fun createMockHooksFeed(): HooksFeed = HooksFeed( hooks = listOf( createMockLocalHookData(Id("hook-1")), createMockLocalHookData(Id("hook-2")), createMockLocalHookData(Id("hook-3")), ), ) private fun createMockLocalHookData( hookId: Id = Id("default-hook"), currentUserLiked: Boolean = false, likeCount: Int = 0, handle: UserHandle? = null, currentUserFollowsCreator: Boolean = false, recommendationItemId: Id? = null, ): LocalHookData = LocalHookData( hookId = hookId, title = "Test title", caption = "Test caption", videoUrl = Url("https://example.com/video.mp4"), thumbnailImageUrl = Url("https://example.com/thumbnail.jpg"), viewCount = 5, likeCount = likeCount, commentCount = 0, currentUserLiked = currentUserLiked, currentUserFollowsCreator = currentUserFollowsCreator, createdAt = LocalDateTime.now(), creator = LocalHookData.Creator( userId = Id("user-123"), handle = handle, avatarUrl = Url("https://example.com/avatar.jpg"), displayName = "Test Artist", ), clip = LocalClipData( clipId = Id("clip-123"), mediaType = SunoMediaType.VIDEO, mediaUrl = Url("https://example.com/video.mp4"), artistName = "Test Artist", artistUserId = Id("user-123"), handle = handle, artistAvatarUrl = Url("https://example.com/avatar.jpg"), nowPlayingTitle = "Test Song", albumImageUrl = Url("https://example.com/album.jpg"), videoCoverUrl = null, caption = "Test caption", isPublic = true, status = ClipStatus.Complete, upvoteCount = likeCount, reaction = if (currentUserLiked) ReactionType.LIKE else null, modelName = "chirp-v3", majorModelVersion = "v3", prompt = null, gptPrompt = null, tags = null, displayTags = null, commentCount = 0, downloadDisabledReason = null, previewUrl = Url("https://example.com/preview.mp4"), playCount = 100, isFollowing = currentUserFollowsCreator, ), recommendationItemId = recommendationItemId, humanRating = null, lyricDisplayType = null, lyrics = null, clipTime = 1.seconds..15.seconds, ) private fun mockSetHookReactionSuccess() { coEvery { hooksRepository.setHookReaction(any(), any(), any(), any()) } returns Either.Right(Unit) } private fun mockToggleFollowCreatorSuccess() { coEvery { profilesRepository.followArtistProfile(any(), any(), any()) } returns Either.Right(Unit) } private fun mockReportInappropriateSuccess() { coEvery { hooksRepository.reportInappropriate(any(), any()) } returns Either.Right(Unit) } private fun mockReportInappropriateError() { coEvery { hooksRepository.reportInappropriate(any(), any()) } returns Either.Left(mockk(relaxed = true)) } }