package com.suno.android.common_analytics.hooks import com.segment.analytics.kotlin.core.Analytics import com.suno.android.common_analytics.providers.DeviceVolumeProvider import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.metadata.Recommendation import com.suno.android.common_data.user.UserSessionConfiguration import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_networking.remote.session.User import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import org.junit.Assert.assertEquals import org.junit.Test import java.time.LocalDateTime import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds private const val USER_ID = "test-user-id" private const val USER_HANDLE = "@testuser" private const val DEVICE_VOLUME = 0.75f @Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) class HookAnalyticsManagerImplTest { private val analytics = mockk(relaxed = true) private val userSessionRepository = mockk() private val testDispatcher = StandardTestDispatcher() private val applicationScope = TestScope(testDispatcher) private val deviceVolumeProvider = mockk() private val loggerFactory = mockk(relaxed = true) private val subject = run { every { deviceVolumeProvider.getCurrentStreamMusicVolume() } returns DEVICE_VOLUME every { userSessionRepository.sessionConfigurationStateFlow() } returns MutableStateFlow( UserSessionConfiguration( user = createTestUser(), roles = null, statsigCustomProperties = null, ), ).asStateFlow() HookAnalyticsManagerImpl( loggerFactory = loggerFactory, applicationScope = applicationScope, dispatcherIO = testDispatcher, analytics = analytics, userSessionRepository = userSessionRepository, deviceVolumeProvider = deviceVolumeProvider, ) } @Test fun `when trackPlayNewHook called then tracks playNewHook event`() = runTest(testDispatcher) { val hook = createTestHookData() val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 150.milliseconds, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "PlayNewHook", expectedStartTimeSecs = 0.0, expectedEndTimeSecs = 0.0, expectedPlayDurationSecs = 0.0, expectedTimeToFirstFrameSecs = 0.15, ) } @Test fun `when trackPlayHook called then tracks playHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.PlayHook( hook = hook, playStartMillis = 1_000.milliseconds, isHookMuted = false, timeToFirstFrame = 200.milliseconds, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "PlayHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 1.0, expectedPlayDurationSecs = 0.0, expectedTimeToFirstFrameSecs = 0.2, ) } @Test fun `when trackPauseHook called then tracks pauseHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.PauseHook( hook = hook, playStartMillis = 1_000.milliseconds, playEndMillis = 5_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "PauseHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 5.0, expectedPlayDurationSecs = 4.0, ) } @Test fun `when trackAutoRepeatPauseHook called then tracks autoRepeatPauseHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.AutoRepeatPauseHook( hook = hook, playStartMillis = 1_000.milliseconds, playEndMillis = 10_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "AutoRepeatPauseHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 10.0, expectedPlayDurationSecs = 9.0, ) } @Test fun `when trackAutoRepeatPlayHook called then tracks autoRepeatPlayHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track(HookAnalyticsPlaybackEvent.AutoRepeatPlayHook(hook = hook, isHookMuted = false)) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "AutoRepeatPlayHook", expectedStartTimeSecs = 0.0, expectedEndTimeSecs = 0.0, expectedPlayDurationSecs = 0.0, ) } @Test fun `when trackScrollUpPauseHook called then tracks scroll up pause event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.ScrollUpPauseHook( hook = hook, playStartMillis = 1_000.milliseconds, playEndMillis = 10_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "ScrollUpPauseHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 10.0, expectedPlayDurationSecs = 9.0, ) } @Test fun `when trackScrollDownPauseHook called then tracks scrollDownPauseHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.ScrollDownPauseHook( hook = hook, playStartMillis = 1_000.milliseconds, playEndMillis = 10_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "ScrollDownPauseHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 10.0, expectedPlayDurationSecs = 9.0, ) } @Test fun `when trackScrollUpPlayNewHook called then tracks scrollUpPlayNewHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() val propertiesSlots = mutableListOf() subject.track( HookAnalyticsPlaybackEvent.ScrollUpPlayNewHook( hook = hook, playStartMillis = 1_000.milliseconds, isHookMuted = false, timeToFirstFrame = 120.milliseconds, ), ) advanceUntilIdle() verify { analytics.track( name = "App-Event", properties = capture(propertiesSlots), ) } propertiesSlots[1].assert( expectedActionName = "ScrollUpPlayNewHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 1.0, expectedPlayDurationSecs = 0.0, expectedPreviousSessionId = propertiesSlots[0].context["hookSessionId"].toString(), expectedTimeToFirstFrameSecs = 0.12, ) } @Test fun `when trackScrollDownPlayNewHook called then tracks scrollDownPlayNewHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() val propertiesSlots = mutableListOf() subject.track( HookAnalyticsPlaybackEvent.ScrollDownPlayNewHook( hook = hook, playStartMillis = 1_000.milliseconds, isHookMuted = false, timeToFirstFrame = 130.milliseconds, ), ) advanceUntilIdle() verify(exactly = 2) { analytics.track( name = "App-Event", properties = capture(propertiesSlots), ) } propertiesSlots[1].assert( expectedActionName = "ScrollDownPlayNewHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 1.0, expectedPlayDurationSecs = 0.0, expectedPreviousSessionId = null, // HookSessionId does not update on ScrollDownPlayNewHook per specs expectedTimeToFirstFrameSecs = 0.13, ) } @Test fun `when multiple sessions then maintains session history`() = runTest(testDispatcher) { val hook = createTestHookData() val propertiesSlots = mutableListOf() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) subject.track( HookAnalyticsPlaybackEvent.ScrollUpPlayNewHook( hook = hook, playStartMillis = 0.milliseconds, isHookMuted = false, timeToFirstFrame = 110.milliseconds, ), ) subject.track( HookAnalyticsPlaybackEvent.ScrollDownPlayNewHook( hook = hook, playStartMillis = 5_000.milliseconds, isHookMuted = false, timeToFirstFrame = 120.milliseconds, ), ) advanceUntilIdle() verify(exactly = 3) { analytics.track( name = "App-Event", properties = capture(propertiesSlots), ) } propertiesSlots[0].assert( expectedActionName = "PlayNewHook", expectedStartTimeSecs = 0.0, expectedEndTimeSecs = 0.0, expectedPlayDurationSecs = 0.0, expectedPreviousSessionId = null, expectedTimeToFirstFrameSecs = 0.1, ) propertiesSlots[1].assert( expectedActionName = "ScrollUpPlayNewHook", expectedStartTimeSecs = 0.0, expectedEndTimeSecs = 0.0, expectedPlayDurationSecs = 0.0, expectedPreviousSessionId = propertiesSlots[0].context["hookSessionId"].toString(), expectedTimeToFirstFrameSecs = 0.11, ) propertiesSlots[2].assert( expectedActionName = "ScrollDownPlayNewHook", expectedStartTimeSecs = 5.0, expectedEndTimeSecs = 5.0, expectedPlayDurationSecs = 0.0, expectedPreviousSessionId = propertiesSlots[0].context["hookSessionId"].toString(), expectedTimeToFirstFrameSecs = 0.12, ) } @Test fun `when trackTapSongPillPauseHook called then tracks TapSongPillPauseHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.TapSongPillPauseHook( hook = hook, playStartMillis = 1_000.milliseconds, playEndMillis = 5_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "TapSongPillPauseHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 5.0, expectedPlayDurationSecs = 4.0, ) } @Test fun `when trackTapTabPauseHook called then tracks TapTabPauseHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.TapTabPauseHook( hook = hook, playStartMillis = 1_000.milliseconds, playEndMillis = 5_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "TapTabPauseHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 5.0, expectedPlayDurationSecs = 4.0, ) } @Test fun `when trackTapTabPlayHook called then tracks TapTabPlayHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.TapTabPlayHook( hook = hook, playStartMillis = 2_000.milliseconds, isHookMuted = false, timeToFirstFrame = 180.milliseconds, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "TapTabPlayHook", expectedStartTimeSecs = 2.0, expectedEndTimeSecs = 2.0, expectedPlayDurationSecs = 0.0, expectedTimeToFirstFrameSecs = 0.18, ) } @Test fun `when trackTapCreatePauseHook called then tracks TapCreateSongPauseHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.TapCreateSongPauseHook( hook = hook, playStartMillis = 1_000.milliseconds, playEndMillis = 8_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "TapCreateSongPauseHook", expectedStartTimeSecs = 1.0, expectedEndTimeSecs = 8.0, expectedPlayDurationSecs = 7.0, ) } @Test fun `when trackCloseCreatePlayHook called then tracks CloseCreateSongPlayHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.CloseCreateSongPlayHook( hook = hook, playStartMillis = 3_000.milliseconds, isHookMuted = false, timeToFirstFrame = 160.milliseconds, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "CloseCreateSongPlayHook", expectedStartTimeSecs = 3.0, expectedEndTimeSecs = 3.0, expectedPlayDurationSecs = 0.0, expectedTimeToFirstFrameSecs = 0.16, ) } @Test fun `when trackTapProfilePauseHook called then tracks TapProfilePauseHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.TapProfilePauseHook( hook = hook, playStartMillis = 2_000.milliseconds, playEndMillis = 7_000.milliseconds, isHookMuted = false, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "TapProfilePauseHook", expectedStartTimeSecs = 2.0, expectedEndTimeSecs = 7.0, expectedPlayDurationSecs = 5.0, ) } @Test fun `when trackTapBackPlayHook called then tracks TapBackPlayHook event`() = runTest(testDispatcher) { val hook = createTestHookData() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 100.milliseconds, ), ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.TapBackPlayHook( hook = hook, playStartMillis = 4_000.milliseconds, isHookMuted = false, timeToFirstFrame = 190.milliseconds, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "TapBackPlayHook", expectedStartTimeSecs = 4.0, expectedEndTimeSecs = 4.0, expectedPlayDurationSecs = 0.0, expectedTimeToFirstFrameSecs = 0.19, ) } @Test fun `when trackCloseClipPlayerStartHook called then tracks closeClipPlayerStartHook event`() = runTest(testDispatcher) { val hook = createTestHookData() val previousClipSessionId = Id("clip-session-123") val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.CloseClipPlayerPlayHook( hook = hook, playStartMillis = 5_000.milliseconds, previousClipSessionId = previousClipSessionId, isHookMuted = false, timeToFirstFrame = 140.milliseconds, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "CloseOmniPlayerStartHook", expectedStartTimeSecs = 5.0, expectedEndTimeSecs = 5.0, expectedPlayDurationSecs = 0.0, expectedTimeToFirstFrameSecs = 0.14, ) } @Test fun `when trackPlayNewHook called on user's own hook then tracks playNewHook event`() = runTest(testDispatcher) { val hook = createTestHookData( creatorUserId = USER_ID, creatorUserHandle = USER_HANDLE, ) val propertiesSlot = slot() subject.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = false, timeToFirstFrame = 150.milliseconds, ), ) advanceUntilIdle() verify(exactly = 1) { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "PlayNewHook", expectedStartTimeSecs = 0.0, expectedEndTimeSecs = 0.0, expectedPlayDurationSecs = 0.0, expectedIsUserHookOwner = true, expectedTimeToFirstFrameSecs = 0.15, ) } private fun createTestHookData( creatorUserId: String = "different-user-id", creatorUserHandle: String = "@differenttestuser", recommendationItemId: Id? = Id("rec-123"), ): LocalHookData = LocalHookData( hookId = Id("hook-123"), title = "Test title", caption = "Test Hook Caption", videoUrl = Url("https://example.com/video.mp4"), thumbnailImageUrl = Url("https://example.com/thumb.jpg"), viewCount = 200, likeCount = 42, commentCount = 10, currentUserLiked = false, currentUserFollowsCreator = false, creator = LocalHookData.Creator( userId = Id(creatorUserId), handle = UserHandle(creatorUserHandle), avatarUrl = Url("https://example.com/avatar.jpg"), displayName = "Test User", ), clip = mockk(), recommendationItemId = recommendationItemId, createdAt = LocalDateTime.now(), humanRating = null, lyricDisplayType = null, lyrics = null, clipTime = 1.seconds..15.seconds, ) private fun createTestUser(): User = User( id = USER_ID, handle = USER_HANDLE, avatarImageUrl = "https://example.com/avatar.jpg", displayName = "Test User", email = "test@example.com", isHandleUpdated = true, profileDescription = "Test bio", username = "testuser", ) private fun JsonObject.assert( expectedActionName: String, expectedStartTimeSecs: Double, expectedEndTimeSecs: Double, expectedPlayDurationSecs: Double, expectedPreviousSessionId: String? = null, expectedIsUserHookOwner: Boolean = false, expectedTimeToFirstFrameSecs: Double? = null, ) { assertEquals(expectedActionName, this["actionName"].toString().removeSurrounding("\"")) assertEquals(expectedStartTimeSecs.toString(), context["startTime"].toString()) assertEquals(expectedEndTimeSecs.toString(), context["endTime"].toString()) assertEquals("android", context["platform"].toString().removeSurrounding("\"")) assertEquals(expectedIsUserHookOwner.toString(), context["isUserHookOwner"].toString()) assertEquals(expectedPlayDurationSecs.toString(), context["playDuration"].toString()) if (expectedPreviousSessionId != null) { assertEquals(expectedPreviousSessionId, context["previousHookSessionId"].toString()) } else { assertEquals("null", context["previousHookSessionId"].toString()) } if (expectedTimeToFirstFrameSecs != null) { assertEquals(expectedTimeToFirstFrameSecs.toString(), context["timeToFirstFrame"].toString()) } else { assertEquals("null", context["timeToFirstFrame"].toString()) } } private val JsonObject.context: JsonObject get() = this["context"] as JsonObject }