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_data.mappers.clips.LocalClipData import com.suno.android.common_data.user.UserProfile 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.Assert.assertNotNull import org.junit.Test import kotlin.time.Duration.Companion.milliseconds private const val USER_ID = "test-user-id" private const val DEVICE_VOLUME = 0.75f @OptIn(ExperimentalCoroutinesApi::class) class HookClipAnalyticsManagerImplTest { private val previousHookSessionId = Id("previous-hook-session-123") private val analytics = mockk(relaxed = true) private val userSessionRepository = mockk { every { sessionConfigurationStateFlow() } returns MutableStateFlow( UserSessionConfiguration( user = createTestUser(), roles = null, statsigCustomProperties = null, ), ).asStateFlow() } private val testDispatcher = StandardTestDispatcher() private val applicationScope = TestScope(testDispatcher) private val deviceVolumeProvider = mockk { every { getCurrentStreamMusicVolume() } returns DEVICE_VOLUME } private val loggerFactory = mockk(relaxed = true) private val subject = HookClipAnalyticsManagerImpl( loggerFactory = loggerFactory, applicationScope = applicationScope, dispatcherIO = testDispatcher, analytics = analytics, userSessionRepository = userSessionRepository, deviceVolumeProvider = deviceVolumeProvider, ) @Test fun `when trackOpenClipPlayerPlaySong called then tracks openClipPlayerPlaySong event`() = runTest(testDispatcher) { val clip = createTestClipData() val propertiesSlot = slot() subject.trackOpenClipPlayerPlaySong( clip = clip, previousHookSessionId = previousHookSessionId, ) advanceUntilIdle() verify { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "TapSongPillPlayNewSong", expectedStartTimeSecs = 0.0, expectedEndTimeSecs = 0.0, expectedPlayDurationSecs = 0.0, expectedPreviousHookSessionId = previousHookSessionId, expectedIsUserClipOwner = false, ) } @Test fun `when trackCloseClipPlayerPauseSong called then tracks closeClipPlayerPauseSong event`() = runTest(testDispatcher) { val clip = createTestClipData() subject.trackOpenClipPlayerPlaySong( clip = clip, previousHookSessionId = previousHookSessionId, ) advanceUntilIdle() clearMocks(analytics, answers = false) val propertiesSlot = slot() subject.trackCloseClipPlayerPauseSong( clip = clip, playStartMillis = 0.milliseconds, playEndMillis = 30_000.milliseconds, playDurationMillis = 5_000.milliseconds, // mock skipped to 30s ) advanceUntilIdle() verify { analytics.track( name = "App-Event", properties = capture(propertiesSlot), ) } propertiesSlot.captured.assert( expectedActionName = "CloseOmniPlayerPauseSong", expectedStartTimeSecs = 0.0, expectedEndTimeSecs = 30.0, expectedPlayDurationSecs = 5.0, expectedPreviousHookSessionId = previousHookSessionId, expectedIsUserClipOwner = false, ) } @Test fun `given session exists when getCurrentSessionId called then returns session id`() = runTest(testDispatcher) { val clip = createTestClipData() subject.trackOpenClipPlayerPlaySong( clip = clip, previousHookSessionId = previousHookSessionId, ) advanceUntilIdle() val sessionId = subject.getCurrentSessionId() assertNotNull(sessionId) } private fun createTestClipData( artistUserId: String = "different-user-id", ): LocalClipData { val clip = mockk() val artistId = Id(artistUserId) every { clip.artistUserId } returns artistId return clip } private fun createTestUser(): User = User( id = USER_ID, handle = "@testuser", 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, expectedPreviousHookSessionId: Id?, expectedIsUserClipOwner: Boolean, ) { assertEquals(expectedActionName, this["actionName"].toString().removeSurrounding("\"")) assertEquals(expectedStartTimeSecs.toString(), context["startTime"].toString()) assertEquals(expectedEndTimeSecs.toString(), context["endTime"].toString()) assertEquals(expectedPlayDurationSecs.toString(), context["playDuration"].toString()) assertEquals("android", context["platform"].toString().removeSurrounding("\"")) assertEquals(expectedIsUserClipOwner.toString(), context["isUserClipOwner"].toString()) assertEquals("0.75", context["volume"].toString()) // 15/20 = 0.75 assertEquals("null", context["clickSourceUrl"].toString()) if (expectedPreviousHookSessionId != null) { assertEquals( expectedPreviousHookSessionId.value, context["previousHookSessionId"].toString().removeSurrounding("\""), ) } else { assertEquals("null", context["previousHookSessionId"].toString()) } val clipSessionId = context["clipSessionId"].toString().removeSurrounding("\"") assert( clipSessionId.matches( Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"), ), ) } private val JsonObject.context: JsonObject get() = this["context"] as JsonObject }