From 388a1d3212520ee6fb35b0b0edd187a805e58a5b Mon Sep 17 00:00:00 2001 From: Chamika Date: Sun, 3 May 2026 13:11:50 +0100 Subject: [PATCH 1/5] Add FirebaseUtils tests covering safe crash reporting wrappers Tests verify that all safe wrappers (safeRecordException, safeLog, safeSetCustomKey) correctly delegate to FirebaseCrashlytics and swallow exceptions when GMS/Crashlytics is unavailable, preventing app crashes on cars that just received an OTA update. Co-Authored-By: Claude Sonnet 4.6 --- .../com/chamika/dashtune/FirebaseUtilsTest.kt | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 automotive/src/test/java/com/chamika/dashtune/FirebaseUtilsTest.kt 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 + } +} From 44e5ff1f6e239a13b7452da77c0a77a10f6af4d2 Mon Sep 17 00:00:00 2001 From: Chamika Date: Sun, 3 May 2026 13:12:23 +0100 Subject: [PATCH 2/5] Add Authenticator tests covering Android AccountAuthenticator behaviour Tests cover token retrieval (happy path and error paths for invalid type and null account), intent bundle returned when no cached password, and the no-op/unsupported stubs required by AbstractAccountAuthenticator. Co-Authored-By: Claude Sonnet 4.6 --- .../dashtune/auth/AuthenticatorTest.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 automotive/src/test/java/com/chamika/dashtune/auth/AuthenticatorTest.kt 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)) + } +} From 7e3eee1706dbef44aa553c36cb4c8a5e774cc7ce Mon Sep 17 00:00:00 2001 From: Chamika Date: Sun, 3 May 2026 13:23:24 +0100 Subject: [PATCH 3/5] Add SignInViewModel tests and test dependencies for LiveData/Room Tests cover pingServer status-200/non-200/exception paths, login error paths, and the loginSuccess side-effects (account storage and loggedIn LiveData) via reflection. Adds core-testing for InstantTaskExecutorRule and room-testing for upcoming DAO tests. Co-Authored-By: Claude Sonnet 4.6 --- automotive/build.gradle.kts | 2 + .../dashtune/signin/SignInViewModelTest.kt | 166 ++++++++++++++++++ gradle/libs.versions.toml | 3 + 3 files changed, 171 insertions(+) create mode 100644 automotive/src/test/java/com/chamika/dashtune/signin/SignInViewModelTest.kt 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/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" } From 8e87f6bba8642d477c643746e3268371b861d616 Mon Sep 17 00:00:00 2001 From: Chamika Date: Sun, 3 May 2026 13:26:01 +0100 Subject: [PATCH 4/5] Add SettingsViewModel tests covering logout side-effects Tests verify logout delegates to accountManager and mediaCacheDao, sends ACTION_STOP_PLAYBACK intent to DashTuneMusicService, and clears all six playback-state keys from SharedPreferences. Uses a spy on the Robolectric Application to stub startService (Hilt service initialisation is incompatible with unit tests) while preserving real SharedPreferences. Co-Authored-By: Claude Sonnet 4.6 --- .../settings/SettingsViewModelTest.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 automotive/src/test/java/com/chamika/dashtune/settings/SettingsViewModelTest.kt 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() } + } +} From 7987ac95f0249e0fa0a7c69baa63f22169673b34 Mon Sep 17 00:00:00 2001 From: Chamika Date: Sun, 3 May 2026 13:26:39 +0100 Subject: [PATCH 5/5] Add MediaCacheDao integration tests using in-memory Room database Tests cover all DAO operations: insert/replace, getItem, getChildrenByParent (including sort-order guarantee), getParentIds, deleteByParent, deleteAll, and hasData. Also validates that all entity fields round-trip correctly through the schema, catching any silent type-conversion issues in Room annotations. Co-Authored-By: Claude Sonnet 4.6 --- .../dashtune/data/db/MediaCacheDaoTest.kt | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 automotive/src/test/java/com/chamika/dashtune/data/db/MediaCacheDaoTest.kt 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) + } +}