package com.suno.android.media.hooks import arrow.core.Either import arrow.retrofit.adapter.either.networkhandling.UnexpectedCallError import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.repos.HooksRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Ignore import org.junit.Test class HooksPlayCountManagerTest { private val hookId = Id("hookId") private lateinit var hooksRepository: HooksRepository private lateinit var testScope: TestScope private val loggerFactory = mockk(relaxed = true) private lateinit var subject: HooksPlayCountManager @Before fun setUp() { hooksRepository = mockk(relaxed = true) { coEvery { updatePlayCounts(any()) } returns Either.Right(Unit) } testScope = TestScope(StandardTestDispatcher()) subject = HooksPlayCountManager( loggerFactory = loggerFactory, hooksRepository = hooksRepository, scope = testScope, ) } @Test fun `when markAsWatched called adds to batch`() = testScope.runTest { subject.markAsWatched(hookId) runCurrent() assertEquals(mapOf(hookId to 1), subject.playCountsBatch) verifyNoRepositoryCalls() } @Test fun `when markAsWatched called with same id then accumulates play count`() = testScope.runTest { repeat(3) { subject.markAsWatched(hookId) runCurrent() } assertEquals(mapOf(hookId to 3), subject.playCountsBatch) verifyNoRepositoryCalls() } @Test fun `given max batch size when markAsWatched called then flushes play counts immediately`() = testScope.runTest { val hookIds = (1..MAX_BATCH_SIZE).map { Id("hookId$it") } hookIds.forEach { hookId -> subject.markAsWatched(hookId) } advanceUntilIdle() coVerify { hooksRepository.updatePlayCounts( hookIds.associateWith { 1 }, ) } } @Test fun `when flush timer expires then flushes all play counts`() = testScope.runTest { subject.markAsWatched(hookId) advanceTimeBy(FLUSH_INTERVAL_MS + 1) coVerify { hooksRepository.updatePlayCounts(mapOf(hookId to 1)) } assert(subject.playCountsBatch.isEmpty()) } @Test fun `given empty batch when flushPlayCounts called then does not call repository`() = runTest { subject.flushPlayCounts() advanceUntilIdle() verifyNoRepositoryCalls() } @Test @Ignore("There's an issue with restarting flush timer and testing, will take a look and address later.") fun `given repository error when flushPlayCounts called then re-adds failed count to batch`() = testScope.runTest { coEvery { hooksRepository.updatePlayCounts(any()) } returns Either.Left(UnexpectedCallError(RuntimeException("Network error"))) subject.markAsWatched(hookId) subject.flushPlayCounts() advanceUntilIdle() coVerify { hooksRepository.updatePlayCounts(mapOf(hookId to 1)) } assertEquals(mapOf(hookId to 1), subject.playCountsBatch) } @Test fun `when flushPlayCounts called multiple times then operations are mutex protected`() = testScope.runTest { subject.markAsWatched(hookId) repeat(3) { subject.flushPlayCounts() } advanceUntilIdle() coVerify(exactly = 1) { hooksRepository.updatePlayCounts(any()) } } private fun verifyNoRepositoryCalls() { coVerify(exactly = 0) { hooksRepository.updatePlayCounts(any()) } } }