diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts index 51f793b..76b68a5 100644 --- a/automotive/build.gradle.kts +++ b/automotive/build.gradle.kts @@ -94,6 +94,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.androidx.media3.test.utils) + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/automotive/src/test/java/com/chamika/dashtune/FirebaseUtilsTest.kt b/automotive/src/test/java/com/chamika/dashtune/FirebaseUtilsTest.kt new file mode 100644 index 0000000..3e06b60 --- /dev/null +++ b/automotive/src/test/java/com/chamika/dashtune/FirebaseUtilsTest.kt @@ -0,0 +1,155 @@ +package com.chamika.dashtune + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FirebaseUtilsTest { + + private lateinit var crashlytics: FirebaseCrashlytics + + @Before + fun setUp() { + crashlytics = mockk(relaxed = true) + mockkStatic(FirebaseCrashlytics::class) + every { FirebaseCrashlytics.getInstance() } returns crashlytics + } + + // --- safeRecordException tests --- + + @Test + fun `safeRecordException records exception to Crashlytics when available`() { + val exception = RuntimeException("test error") + every { crashlytics.recordException(exception) } just runs + + FirebaseUtils.safeRecordException(exception) + + verify { crashlytics.recordException(exception) } + } + + @Test + fun `safeRecordException swallows exception when Crashlytics is unavailable`() { + every { FirebaseCrashlytics.getInstance() } throws RuntimeException("GMS not available") + + FirebaseUtils.safeRecordException(RuntimeException("app error")) + // No exception should propagate + } + + @Test + fun `safeRecordException swallows exception when recordException itself throws`() { + val exception = RuntimeException("test error") + every { crashlytics.recordException(any()) } throws IllegalStateException("Crashlytics not initialised") + + FirebaseUtils.safeRecordException(exception) + // No exception should propagate + } + + // --- safeLog tests --- + + @Test + fun `safeLog logs message to Crashlytics when available`() { + every { crashlytics.log(any()) } just runs + + FirebaseUtils.safeLog("login successful") + + verify { crashlytics.log("login successful") } + } + + @Test + fun `safeLog swallows exception when Crashlytics is unavailable`() { + every { FirebaseCrashlytics.getInstance() } throws RuntimeException("GMS not available") + + FirebaseUtils.safeLog("some message") + // No exception should propagate + } + + @Test + fun `safeLog swallows exception when log itself throws`() { + every { crashlytics.log(any()) } throws IllegalStateException("Crashlytics not initialised") + + FirebaseUtils.safeLog("some message") + // No exception should propagate + } + + // --- safeSetCustomKey(String, String) tests --- + + @Test + fun `safeSetCustomKey sets string key when Crashlytics is available`() { + every { crashlytics.setCustomKey(any(), any()) } just runs + + FirebaseUtils.safeSetCustomKey("auth_method", "password") + + verify { crashlytics.setCustomKey("auth_method", "password") } + } + + @Test + fun `safeSetCustomKey string overload swallows exception when Crashlytics unavailable`() { + every { FirebaseCrashlytics.getInstance() } throws RuntimeException("GMS not available") + + FirebaseUtils.safeSetCustomKey("key", "value") + // No exception should propagate + } + + @Test + fun `safeSetCustomKey string overload swallows exception when setCustomKey throws`() { + every { crashlytics.setCustomKey(any(), any()) } throws RuntimeException("crash") + + FirebaseUtils.safeSetCustomKey("key", "value") + // No exception should propagate + } + + // --- safeSetCustomKey(String, Int) tests --- + + @Test + fun `safeSetCustomKey sets int key when Crashlytics is available`() { + every { crashlytics.setCustomKey(any(), any()) } just runs + + FirebaseUtils.safeSetCustomKey("retry_count", 3) + + verify { crashlytics.setCustomKey("retry_count", 3) } + } + + @Test + fun `safeSetCustomKey int overload swallows exception when Crashlytics unavailable`() { + every { FirebaseCrashlytics.getInstance() } throws RuntimeException("GMS not available") + + FirebaseUtils.safeSetCustomKey("key", 42) + // No exception should propagate + } + + // --- safeSetCustomKey(String, Boolean) tests --- + + @Test + fun `safeSetCustomKey sets boolean key when Crashlytics is available`() { + every { crashlytics.setCustomKey(any(), any()) } just runs + + FirebaseUtils.safeSetCustomKey("is_premium", true) + + verify { crashlytics.setCustomKey("is_premium", true) } + } + + @Test + fun `safeSetCustomKey boolean overload swallows exception when Crashlytics unavailable`() { + every { FirebaseCrashlytics.getInstance() } throws RuntimeException("GMS not available") + + FirebaseUtils.safeSetCustomKey("key", false) + // No exception should propagate + } + + @Test + fun `safeSetCustomKey boolean overload swallows exception when setCustomKey throws`() { + every { crashlytics.setCustomKey(any(), any()) } throws RuntimeException("crash") + + FirebaseUtils.safeSetCustomKey("key", true) + // No exception should propagate + } +} diff --git a/automotive/src/test/java/com/chamika/dashtune/auth/AuthenticatorTest.kt b/automotive/src/test/java/com/chamika/dashtune/auth/AuthenticatorTest.kt new file mode 100644 index 0000000..efbe449 --- /dev/null +++ b/automotive/src/test/java/com/chamika/dashtune/auth/AuthenticatorTest.kt @@ -0,0 +1,145 @@ +package com.chamika.dashtune.auth + +import android.accounts.Account +import android.accounts.AccountManager +import android.os.Bundle +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AuthenticatorTest { + + private val context get() = RuntimeEnvironment.getApplication() + private lateinit var accountManager: AccountManager + private lateinit var authenticator: Authenticator + + @Before + fun setUp() { + accountManager = mockk(relaxed = true) + mockkStatic(AccountManager::class) + every { AccountManager.get(context) } returns accountManager + authenticator = Authenticator(context) + } + + // --- constants --- + + @Test + fun `ACCOUNT_TYPE is the app package name`() { + assertEquals("com.chamika.dashtune", Authenticator.ACCOUNT_TYPE) + } + + @Test + fun `AUTHTOKEN_TYPE equals ACCOUNT_TYPE`() { + assertEquals(Authenticator.ACCOUNT_TYPE, Authenticator.AUTHTOKEN_TYPE) + } + + // --- editProperties --- + + @Test(expected = UnsupportedOperationException::class) + fun `editProperties throws UnsupportedOperationException`() { + authenticator.editProperties(null, Authenticator.ACCOUNT_TYPE) + } + + // --- addAccount --- + + @Test + fun `addAccount returns empty Bundle`() { + val result = authenticator.addAccount(null, Authenticator.ACCOUNT_TYPE, null, null, null) + + assertNotNull(result) + assertTrue(result.isEmpty) + } + + // --- confirmCredentials --- + + @Test + fun `confirmCredentials returns null`() { + val account = Account("user", Authenticator.ACCOUNT_TYPE) + assertNull(authenticator.confirmCredentials(null, account, null)) + } + + // --- getAuthToken --- + + @Test + fun `getAuthToken returns error bundle when authTokenType is invalid`() { + val account = Account("user", Authenticator.ACCOUNT_TYPE) + val result = authenticator.getAuthToken(null, account, "wrong.type", null) + + assertEquals("invalid auth token type", result.getString(AccountManager.KEY_ERROR_MESSAGE)) + } + + @Test + fun `getAuthToken returns error bundle when account is null`() { + val result = authenticator.getAuthToken(null, null, Authenticator.AUTHTOKEN_TYPE, null) + + assertEquals("account must not be null", result.getString(AccountManager.KEY_ERROR_MESSAGE)) + } + + @Test + fun `getAuthToken returns token bundle when password exists`() { + val account = Account("testuser", Authenticator.ACCOUNT_TYPE) + every { accountManager.getPassword(account) } returns "stored-token" + + val result = authenticator.getAuthToken(null, account, Authenticator.AUTHTOKEN_TYPE, null) + + assertEquals("testuser", result.getString(AccountManager.KEY_ACCOUNT_NAME)) + assertEquals(Authenticator.ACCOUNT_TYPE, result.getString(AccountManager.KEY_ACCOUNT_TYPE)) + assertEquals("stored-token", result.getString(AccountManager.KEY_AUTHTOKEN)) + } + + @Test + fun `getAuthToken returns intent bundle when no password stored`() { + val account = Account("testuser", Authenticator.ACCOUNT_TYPE) + every { accountManager.getPassword(account) } returns null + + val result = authenticator.getAuthToken(null, account, Authenticator.AUTHTOKEN_TYPE, null) + + assertNotNull(result.getParcelable(AccountManager.KEY_INTENT, android.content.Intent::class.java)) + } + + @Test + fun `getAuthToken with valid token does not include error message`() { + val account = Account("user", Authenticator.ACCOUNT_TYPE) + every { accountManager.getPassword(account) } returns "valid-token" + + val result = authenticator.getAuthToken(null, account, Authenticator.AUTHTOKEN_TYPE, null) + + assertNull(result.getString(AccountManager.KEY_ERROR_MESSAGE)) + } + + // --- getAuthTokenLabel --- + + @Test + fun `getAuthTokenLabel returns null`() { + assertNull(authenticator.getAuthTokenLabel(Authenticator.AUTHTOKEN_TYPE)) + } + + // --- updateCredentials --- + + @Test + fun `updateCredentials returns null`() { + val account = Account("user", Authenticator.ACCOUNT_TYPE) + assertNull(authenticator.updateCredentials(null, account, null, null)) + } + + // --- hasFeatures --- + + @Test + fun `hasFeatures returns bundle with KEY_BOOLEAN_RESULT false`() { + val account = Account("user", Authenticator.ACCOUNT_TYPE) + val result = authenticator.hasFeatures(null, account, emptyArray()) + + assertFalse(result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)) + } +} diff --git a/automotive/src/test/java/com/chamika/dashtune/data/db/MediaCacheDaoTest.kt b/automotive/src/test/java/com/chamika/dashtune/data/db/MediaCacheDaoTest.kt new file mode 100644 index 0000000..2b6e098 --- /dev/null +++ b/automotive/src/test/java/com/chamika/dashtune/data/db/MediaCacheDaoTest.kt @@ -0,0 +1,264 @@ +package com.chamika.dashtune.data.db + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class MediaCacheDaoTest { + + private lateinit var database: DashTuneDatabase + private lateinit var dao: MediaCacheDao + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + DashTuneDatabase::class.java + ).allowMainThreadQueries().build() + dao = database.mediaCacheDao() + } + + @After + fun tearDown() { + database.close() + } + + // --- helpers --- + + private fun item( + mediaId: String, + parentId: String, + title: String = "Title", + sortOrder: Int = 0 + ) = CachedMediaItemEntity( + mediaId = mediaId, + parentId = parentId, + title = title, + subtitle = null, + artUri = null, + mediaType = 1, + isPlayable = true, + isBrowsable = false, + sortOrder = sortOrder, + durationMs = null, + isFavorite = false, + extras = null + ) + + // --- hasData --- + + @Test + fun `hasData returns false when database is empty`() = runTest { + assertFalse(dao.hasData()) + } + + @Test + fun `hasData returns true after inserting an item`() = runTest { + dao.insertAll(listOf(item("id1", "parent1"))) + + assertTrue(dao.hasData()) + } + + // --- insertAll & getItem --- + + @Test + fun `getItem returns null for non-existent mediaId`() = runTest { + assertNull(dao.getItem("nonexistent")) + } + + @Test + fun `getItem returns item after insertion`() = runTest { + val entity = item("media1", "parent1", "My Song") + dao.insertAll(listOf(entity)) + + val result = dao.getItem("media1") + + assertNotNull(result) + assertEquals("media1", result?.mediaId) + assertEquals("My Song", result?.title) + } + + @Test + fun `insertAll replaces existing item with same primary key`() = runTest { + val original = item("id1", "parent1", "Original") + val updated = item("id1", "parent1", "Updated") + dao.insertAll(listOf(original)) + dao.insertAll(listOf(updated)) + + val result = dao.getItem("id1") + + assertEquals("Updated", result?.title) + } + + @Test + fun `insertAll stores multiple items`() = runTest { + dao.insertAll(listOf( + item("id1", "parent1"), + item("id2", "parent1"), + item("id3", "parent2") + )) + + assertNotNull(dao.getItem("id1")) + assertNotNull(dao.getItem("id2")) + assertNotNull(dao.getItem("id3")) + } + + // --- getChildrenByParent --- + + @Test + fun `getChildrenByParent returns empty list when no items for parent`() = runTest { + dao.insertAll(listOf(item("id1", "other-parent"))) + + val result = dao.getChildrenByParent("nonexistent-parent") + + assertTrue(result.isEmpty()) + } + + @Test + fun `getChildrenByParent returns only items matching parentId`() = runTest { + dao.insertAll(listOf( + item("id1", "parent-a"), + item("id2", "parent-a"), + item("id3", "parent-b") + )) + + val result = dao.getChildrenByParent("parent-a") + + assertEquals(2, result.size) + assertTrue(result.all { it.parentId == "parent-a" }) + } + + @Test + fun `getChildrenByParent returns items sorted by sortOrder ascending`() = runTest { + dao.insertAll(listOf( + item("id-c", "parent1", sortOrder = 3), + item("id-a", "parent1", sortOrder = 1), + item("id-b", "parent1", sortOrder = 2) + )) + + val result = dao.getChildrenByParent("parent1") + + assertEquals(listOf(1, 2, 3), result.map { it.sortOrder }) + } + + // --- getParentIds --- + + @Test + fun `getParentIds returns empty list for unknown mediaId`() = runTest { + val result = dao.getParentIds("nonexistent") + + assertTrue(result.isEmpty()) + } + + @Test + fun `getParentIds returns all parent IDs for a mediaId`() = runTest { + dao.insertAll(listOf( + item("track1", "album-a"), + item("track1", "album-b") + )) + + val result = dao.getParentIds("track1") + + assertEquals(2, result.size) + assertTrue(result.containsAll(listOf("album-a", "album-b"))) + } + + // --- deleteByParent --- + + @Test + fun `deleteByParent removes only items with matching parentId`() = runTest { + dao.insertAll(listOf( + item("id1", "parent-a"), + item("id2", "parent-a"), + item("id3", "parent-b") + )) + + dao.deleteByParent("parent-a") + + assertNull(dao.getItem("id1")) + assertNull(dao.getItem("id2")) + assertNotNull(dao.getItem("id3")) + } + + @Test + fun `deleteByParent is no-op when no items match`() = runTest { + dao.insertAll(listOf(item("id1", "parent-x"))) + + dao.deleteByParent("nonexistent-parent") + + assertNotNull(dao.getItem("id1")) + } + + // --- deleteAll --- + + @Test + fun `deleteAll removes all items`() = runTest { + dao.insertAll(listOf( + item("id1", "parent-a"), + item("id2", "parent-b"), + item("id3", "parent-c") + )) + + dao.deleteAll() + + assertFalse(dao.hasData()) + assertNull(dao.getItem("id1")) + assertNull(dao.getItem("id2")) + } + + @Test + fun `deleteAll on empty database does not throw`() = runTest { + dao.deleteAll() + + assertFalse(dao.hasData()) + } + + // --- entity field validation --- + + @Test + fun `stored entity preserves all fields correctly`() = runTest { + val entity = CachedMediaItemEntity( + mediaId = "book-chapter-1", + parentId = "book-1", + title = "Chapter 1", + subtitle = "Part One", + artUri = "content://com.chamika.dashtune/art/book-1", + mediaType = 2, + isPlayable = true, + isBrowsable = false, + sortOrder = 5, + durationMs = 180_000L, + isFavorite = true, + extras = """{"IS_AUDIOBOOK":true}""" + ) + dao.insertAll(listOf(entity)) + + val result = dao.getItem("book-chapter-1")!! + + assertEquals("book-chapter-1", result.mediaId) + assertEquals("book-1", result.parentId) + assertEquals("Chapter 1", result.title) + assertEquals("Part One", result.subtitle) + assertEquals("content://com.chamika.dashtune/art/book-1", result.artUri) + assertEquals(2, result.mediaType) + assertTrue(result.isPlayable) + assertFalse(result.isBrowsable) + assertEquals(5, result.sortOrder) + assertEquals(180_000L, result.durationMs) + assertTrue(result.isFavorite) + assertEquals("""{"IS_AUDIOBOOK":true}""", result.extras) + } +} diff --git a/automotive/src/test/java/com/chamika/dashtune/settings/SettingsViewModelTest.kt b/automotive/src/test/java/com/chamika/dashtune/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000..0b84e91 --- /dev/null +++ b/automotive/src/test/java/com/chamika/dashtune/settings/SettingsViewModelTest.kt @@ -0,0 +1,145 @@ +package com.chamika.dashtune.settings + +import android.app.Application +import android.content.Intent +import androidx.preference.PreferenceManager +import com.chamika.dashtune.DashTuneMusicService +import com.chamika.dashtune.auth.JellyfinAccountManager +import com.chamika.dashtune.data.db.MediaCacheDao +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SettingsViewModelTest { + + private lateinit var context: Application + private lateinit var accountManager: JellyfinAccountManager + private lateinit var mediaCacheDao: MediaCacheDao + private lateinit var viewModel: SettingsViewModel + + @Before + fun setUp() { + // Spy on the real Application to stub startService (DashTuneMusicService requires Hilt + // which cannot be initialised in unit tests) while keeping real SharedPreferences. + context = spyk(RuntimeEnvironment.getApplication()) + every { context.startService(any()) } returns null + + accountManager = mockk(relaxed = true) + mediaCacheDao = mockk(relaxed = true) + viewModel = SettingsViewModel(accountManager, mediaCacheDao, context) + } + + // --- logout --- + + @Test + fun `logout calls accountManager logout`() = runTest { + viewModel.logout() + + verify { accountManager.logout() } + } + + @Test + fun `logout calls mediaCacheDao deleteAll`() = runTest { + viewModel.logout() + + coVerify { mediaCacheDao.deleteAll() } + } + + @Test + fun `logout sends ACTION_STOP_PLAYBACK intent to DashTuneMusicService`() = runTest { + val intentSlot = slot() + every { context.startService(capture(intentSlot)) } returns null + + viewModel.logout() + + assertEquals(DashTuneMusicService.ACTION_STOP_PLAYBACK, intentSlot.captured.action) + assertEquals( + DashTuneMusicService::class.java.name, + intentSlot.captured.component?.className + ) + } + + @Test + fun `logout removes playlistIds from shared preferences`() = runTest { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putString("playlistIds", "id1,id2").apply() + + viewModel.logout() + + assertNull(prefs.getString("playlistIds", null)) + } + + @Test + fun `logout removes playlistIndex from shared preferences`() = runTest { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putInt("playlistIndex", 3).apply() + + viewModel.logout() + + assertEquals(-1, prefs.getInt("playlistIndex", -1)) + } + + @Test + fun `logout removes playlistTrackPositionMs from shared preferences`() = runTest { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putLong("playlistTrackPositionMs", 12345L).apply() + + viewModel.logout() + + assertEquals(-1L, prefs.getLong("playlistTrackPositionMs", -1L)) + } + + @Test + fun `logout removes last_sync_timestamp from shared preferences`() = runTest { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putLong("last_sync_timestamp", 999L).apply() + + viewModel.logout() + + assertEquals(-1L, prefs.getLong("last_sync_timestamp", -1L)) + } + + @Test + fun `logout removes repeat_mode from shared preferences`() = runTest { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putInt("repeat_mode", 1).apply() + + viewModel.logout() + + assertEquals(-1, prefs.getInt("repeat_mode", -1)) + } + + @Test + fun `logout removes shuffle_enabled from shared preferences`() = runTest { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putBoolean("shuffle_enabled", true).apply() + + viewModel.logout() + + assertTrue(!prefs.contains("shuffle_enabled")) + } + + @Test + fun `logout completes without exception when preferences are already empty`() = runTest { + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().apply() + + viewModel.logout() + + verify { accountManager.logout() } + } +} diff --git a/automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt b/automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt new file mode 100644 index 0000000..bc26fec --- /dev/null +++ b/automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt @@ -0,0 +1,166 @@ +package com.chamika.dashtune.signin + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.chamika.dashtune.auth.JellyfinAccountManager +import com.google.firebase.crashlytics.FirebaseCrashlytics +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.operations.SystemApi +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.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import io.mockk.coEvery + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SignInViewModelTest { + + @get:Rule + val instantTaskRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var jellyfin: Jellyfin + private lateinit var accountManager: JellyfinAccountManager + private lateinit var apiClient: ApiClient + private lateinit var systemApi: SystemApi + private lateinit var viewModel: SignInViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + accountManager = mockk(relaxed = true) + apiClient = mockk(relaxed = true) + systemApi = mockk(relaxed = true) + + mockkStatic(FirebaseCrashlytics::class) + every { FirebaseCrashlytics.getInstance() } returns mockk(relaxed = true) + + // Stub options before createApi so createApi$default can resolve default parameters + jellyfin = mockk(relaxed = true) + val mockOptions = mockk(relaxed = true) + every { jellyfin.options } returns mockOptions + every { jellyfin.createApi(any()) } returns apiClient + + mockkStatic("org.jellyfin.sdk.api.client.extensions.ApiClientExtensionsKt") + every { any().systemApi } returns systemApi + + viewModel = SignInViewModel() + viewModel.jellyfin = jellyfin + viewModel.accountManager = accountManager + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // --- JELLYFIN_SERVER_URL constant --- + + @Test + fun `JELLYFIN_SERVER_URL constant has expected value`() { + assertEquals("jellyfinServer", SignInViewModel.JELLYFIN_SERVER_URL) + } + + // --- pingServer --- + + @Test + fun `pingServer returns true when server responds with status 200`() = runTest { + val pingResponse: Response = mockk { every { status } returns 200 } + coEvery { systemApi.getPingSystem() } returns pingResponse + + val result = viewModel.pingServer("http://jellyfin.local:8096") + + assertTrue(result) + } + + @Test + fun `pingServer returns false when server responds with non-200 status`() = runTest { + val pingResponse: Response = mockk { every { status } returns 503 } + coEvery { systemApi.getPingSystem() } returns pingResponse + + val result = viewModel.pingServer("http://jellyfin.local:8096") + + assertFalse(result) + } + + @Test + fun `pingServer returns false when network exception is thrown`() = runTest { + coEvery { systemApi.getPingSystem() } throws RuntimeException("connection refused") + + val result = viewModel.pingServer("http://unreachable.host") + + assertFalse(result) + } + + // --- login --- + + @Test + fun `login returns false when authentication API returns non-200 status`() = runTest { + // Relaxed userApi mock returns a Response with status 0 (not 200) + val result = viewModel.login("http://server.local", "testuser", "wrongpassword") + + assertFalse(result) + } + + @Test + fun `login returns false when network exception is thrown`() = runTest { + every { jellyfin.createApi(any()) } throws RuntimeException("network error") + + val result = viewModel.login("http://server.local", "testuser", "password") + + assertFalse(result) + } + + // --- loginSuccess (tested via reflection) --- + + @Test + fun `loginSuccess stores account in account manager`() = runTest { + invokeLoginSuccess("http://server.local", "chamika", "access-token") + testDispatcher.scheduler.advanceUntilIdle() + + verify { accountManager.storeAccount("http://server.local", "chamika", "access-token") } + } + + @Test + fun `loginSuccess posts true to loggedIn LiveData`() = runTest { + invokeLoginSuccess("http://server.local", "user", "token") + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.loggedIn.value == true) + } + + @Test + fun `loggedIn LiveData starts as null before any authentication`() { + assertFalse(viewModel.loggedIn.value == true) + } + + private fun invokeLoginSuccess(server: String, username: String, token: String) { + val method = SignInViewModel::class.java.getDeclaredMethod( + "loginSuccess", + String::class.java, String::class.java, String::class.java + ) + method.isAccessible = true + method.invoke(viewModel, server, username, token) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb12813..6e713fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ googleServices = "4.4.4" firebaseCrashlyticsPlugin = "3.0.6" slf4jAndroid = "1.7.36" room = "2.7.1" +archCoreTesting = "2.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -55,6 +56,8 @@ slf4j-android = { group = "org.slf4j", name = "slf4j-android", version.ref = "sl androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "archCoreTesting" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }