From b170e9169b2ae2115b9fd2fb06c7694ef51e32a5 Mon Sep 17 00:00:00 2001 From: Stephen Siapno Date: Fri, 13 Jun 2025 00:04:06 +0800 Subject: [PATCH] Add Mockito-based tests for settings module --- README.md | 1 + core/datastore/README.md | 6 +-- .../com/thesetox/datastore/AppDataStore.kt | 24 +++++++++++ .../thesetox/datastore/LocalDataSourceTest.kt | 27 ++++++++++++ feature/README.md | 4 ++ feature/settings/README.md | 13 ++++++ feature/settings/build.gradle.kts | 42 +++++++++++++++++++ feature/settings/consumer-rules.pro | 0 feature/settings/proguard-rules.pro | 21 ++++++++++ .../settings/GetDarkThemeEnabledUseCase.kt | 10 +++++ .../settings/SetDarkThemeEnabledUseCase.kt | 12 ++++++ .../settings/SettingsDataRepository.kt | 18 ++++++++ .../com/thesetox/settings/SettingsModule.kt | 15 +++++++ .../thesetox/settings/SettingsRepository.kt | 16 +++++++ .../settings/SettingsDataRepositoryTest.kt | 37 ++++++++++++++++ .../thesetox/settings/SettingsUseCaseTest.kt | 38 +++++++++++++++++ settings.gradle.kts | 1 + 17 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 feature/settings/README.md create mode 100644 feature/settings/build.gradle.kts create mode 100644 feature/settings/consumer-rules.pro create mode 100644 feature/settings/proguard-rules.pro create mode 100644 feature/settings/src/main/kotlin/com/thesetox/settings/GetDarkThemeEnabledUseCase.kt create mode 100644 feature/settings/src/main/kotlin/com/thesetox/settings/SetDarkThemeEnabledUseCase.kt create mode 100644 feature/settings/src/main/kotlin/com/thesetox/settings/SettingsDataRepository.kt create mode 100644 feature/settings/src/main/kotlin/com/thesetox/settings/SettingsModule.kt create mode 100644 feature/settings/src/main/kotlin/com/thesetox/settings/SettingsRepository.kt create mode 100644 feature/settings/src/test/java/com/thesetox/settings/SettingsDataRepositoryTest.kt create mode 100644 feature/settings/src/test/java/com/thesetox/settings/SettingsUseCaseTest.kt diff --git a/README.md b/README.md index 6f36a77..fcca0f5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ ConversionApp follows a scalable and testable architecture: * `feature:sync` fetches the latest rates from the remote API and updates the database. * `feature:commission` applies dynamic commission logic (e.g. first 5 free, 0.7% after). * `feature:balance` manages the user's wallet-like balance. +* `feature:settings` stores preferences like the dark theme toggle. * `core:network`, `core:datastore`, `core:database`, and `core:designsystem` are reusable base modules shared across features. Each feature module depends only on what it needs and interacts with shared core modules through interfaces. diff --git a/core/datastore/README.md b/core/datastore/README.md index f7e5eab..0b2745c 100644 --- a/core/datastore/README.md +++ b/core/datastore/README.md @@ -1,10 +1,10 @@ # Datastore Module -This module wraps **Jetpack DataStore** so other modules can persist small pieces of data without needing to know the underlying implementation. Currently it stores a hash of the downloaded currency rates which the sync feature uses to determine when new data is available. +This module wraps **Jetpack DataStore** so other modules can persist small pieces of data without needing to know the underlying implementation. It stores the hash of the downloaded currency rates and also exposes helpers for persisting the user's dark theme preference. ## Overview -- **AppDataStore** – interface that exposes `getCurrencyRateHash()` and `saveCurrencyRateHash()`. +- **AppDataStore** – interface that exposes `getCurrencyRateHash()`, `saveCurrencyRateHash()`, `isDarkThemeEnabled()` and `setDarkThemeEnabled()`. - **LocalDataSource** – `AppDataStore` implementation backed by `androidx.datastore.preferences`. - **dataStoreModule** – Koin module that provides a `DataStore` instance named `secure_prefs` and binds `AppDataStore` to `LocalDataSource`. @@ -19,4 +19,4 @@ startKoin { } ``` -Other modules can then inject `AppDataStore` to read or write the saved hash. +Other modules can then inject `AppDataStore` to persist the currency rate hash or the user's dark theme setting. diff --git a/core/datastore/src/main/kotlin/com/thesetox/datastore/AppDataStore.kt b/core/datastore/src/main/kotlin/com/thesetox/datastore/AppDataStore.kt index e265f7c..2245faf 100644 --- a/core/datastore/src/main/kotlin/com/thesetox/datastore/AppDataStore.kt +++ b/core/datastore/src/main/kotlin/com/thesetox/datastore/AppDataStore.kt @@ -2,6 +2,7 @@ package com.thesetox.datastore import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.first @@ -24,7 +25,18 @@ interface AppDataStore { * * @param hash value to store */ + suspend fun saveCurrencyRateHash(hash: String) + + /** + * Check whether dark theme is enabled. + */ + suspend fun isDarkThemeEnabled(): Boolean + + /** + * Persist the dark theme preference. + */ + suspend fun setDarkThemeEnabled(enabled: Boolean) } /** @@ -45,8 +57,20 @@ class LocalDataSource(private val dataStore: DataStore) : AppDataSt } } + override suspend fun isDarkThemeEnabled(): Boolean { + val preferences = dataStore.data.first() + return preferences[DARK_THEME_KEY] ?: false + } + + override suspend fun setDarkThemeEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[DARK_THEME_KEY] = enabled + } + } + companion object { /** Preference key used to store the currency rate hash. */ private val HASH_KEY = stringPreferencesKey("HASH_KEY") + private val DARK_THEME_KEY = booleanPreferencesKey("dark_theme") } } diff --git a/core/datastore/src/test/kotlin/com/thesetox/datastore/LocalDataSourceTest.kt b/core/datastore/src/test/kotlin/com/thesetox/datastore/LocalDataSourceTest.kt index 3174542..29d5997 100644 --- a/core/datastore/src/test/kotlin/com/thesetox/datastore/LocalDataSourceTest.kt +++ b/core/datastore/src/test/kotlin/com/thesetox/datastore/LocalDataSourceTest.kt @@ -41,6 +41,33 @@ class LocalDataSourceTest { // Act localDataSource.saveCurrencyRateHash("abc") + // Assert + verify(dataStore).edit(any()) + } + + @Test + fun `isDarkThemeEnabled reads from dataStore`() = + runTest { + // Arrange + whenever(dataStore.data).thenReturn(flowOf(emptyPreferences())) + + // Act + localDataSource.isDarkThemeEnabled() + + // Assert + verify(dataStore).data + } + + @Test + fun `setDarkThemeEnabled writes to dataStore`() = + runTest { + // Arrange + whenever(dataStore.edit(any())) + .thenReturn(emptyPreferences()) + + // Act + localDataSource.setDarkThemeEnabled(true) + // Assert verify(dataStore).edit(any()) } diff --git a/feature/README.md b/feature/README.md index e296c97..806cc47 100644 --- a/feature/README.md +++ b/feature/README.md @@ -17,3 +17,7 @@ Handles the currency exchange flow, including Compose UI components and several ## sync Responsible for synchronizing currency rates from the remote API. `SyncUseCase` checks if an update is needed, saves rates to the database and stores an MD5 hash to avoid unnecessary work. A background `SyncService` triggers the process. + +## settings + +Stores user preferences such as whether dark theme is enabled. It exposes simple use cases via `settingsModule`. diff --git a/feature/settings/README.md b/feature/settings/README.md new file mode 100644 index 0000000..664e57a --- /dev/null +++ b/feature/settings/README.md @@ -0,0 +1,13 @@ +# Settings Module + +This module stores simple user preferences for **ConversionApp**. It currently keeps a single flag indicating whether dark theme is enabled. + +## Components + +- `SettingsRepository` – interface for reading and updating preferences. +- `SettingsDataRepository` – DataStore based implementation. +- `GetDarkThemeEnabledUseCase` – returns the saved dark theme value. +- `SetDarkThemeEnabledUseCase` – updates the preference. +- `settingsModule` – Koin module exposing the repository and use cases. + +Include `settingsModule` in your Koin setup and depend on `:feature:settings` to read or change the dark theme setting. diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts new file mode 100644 index 0000000..1ff00f8 --- /dev/null +++ b/feature/settings/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.thesetox.settings" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(projects.core.datastore) + implementation(libs.koin.android) + implementation(libs.koin.core) + + testImplementation(libs.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/feature/settings/consumer-rules.pro b/feature/settings/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/settings/proguard-rules.pro b/feature/settings/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/feature/settings/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/feature/settings/src/main/kotlin/com/thesetox/settings/GetDarkThemeEnabledUseCase.kt b/feature/settings/src/main/kotlin/com/thesetox/settings/GetDarkThemeEnabledUseCase.kt new file mode 100644 index 0000000..34a116a --- /dev/null +++ b/feature/settings/src/main/kotlin/com/thesetox/settings/GetDarkThemeEnabledUseCase.kt @@ -0,0 +1,10 @@ +package com.thesetox.settings + +/** + * Retrieves the saved dark theme setting. + */ +class GetDarkThemeEnabledUseCase( + private val repository: SettingsRepository, +) { + suspend operator fun invoke(): Boolean = repository.isDarkThemeEnabled() +} diff --git a/feature/settings/src/main/kotlin/com/thesetox/settings/SetDarkThemeEnabledUseCase.kt b/feature/settings/src/main/kotlin/com/thesetox/settings/SetDarkThemeEnabledUseCase.kt new file mode 100644 index 0000000..7517317 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/thesetox/settings/SetDarkThemeEnabledUseCase.kt @@ -0,0 +1,12 @@ +package com.thesetox.settings + +/** + * Updates the stored dark theme preference. + */ +class SetDarkThemeEnabledUseCase( + private val repository: SettingsRepository, +) { + suspend operator fun invoke(enabled: Boolean) { + repository.setDarkThemeEnabled(enabled) + } +} diff --git a/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsDataRepository.kt b/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsDataRepository.kt new file mode 100644 index 0000000..b7c57ed --- /dev/null +++ b/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsDataRepository.kt @@ -0,0 +1,18 @@ +package com.thesetox.settings + +import com.thesetox.datastore.AppDataStore + +/** + * DataStore-based implementation of [SettingsRepository]. + */ +class SettingsDataRepository( + private val dataStore: AppDataStore, +) : SettingsRepository { + override suspend fun isDarkThemeEnabled(): Boolean { + return dataStore.isDarkThemeEnabled() + } + + override suspend fun setDarkThemeEnabled(enabled: Boolean) { + dataStore.setDarkThemeEnabled(enabled) + } +} diff --git a/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsModule.kt b/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsModule.kt new file mode 100644 index 0000000..2f66bb8 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsModule.kt @@ -0,0 +1,15 @@ +package com.thesetox.settings + +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +/** + * Koin module that exposes the settings repository and use cases. + */ +val settingsModule = + module { + singleOf(::SettingsDataRepository) { bind() } + singleOf(::GetDarkThemeEnabledUseCase) + singleOf(::SetDarkThemeEnabledUseCase) + } diff --git a/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsRepository.kt b/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsRepository.kt new file mode 100644 index 0000000..d2e6f09 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/thesetox/settings/SettingsRepository.kt @@ -0,0 +1,16 @@ +package com.thesetox.settings + +/** + * Repository interface for storing and retrieving user settings. + */ +interface SettingsRepository { + /** + * Returns whether dark theme is enabled. + */ + suspend fun isDarkThemeEnabled(): Boolean + + /** + * Persists the dark theme preference. + */ + suspend fun setDarkThemeEnabled(enabled: Boolean) +} diff --git a/feature/settings/src/test/java/com/thesetox/settings/SettingsDataRepositoryTest.kt b/feature/settings/src/test/java/com/thesetox/settings/SettingsDataRepositoryTest.kt new file mode 100644 index 0000000..f7262b9 --- /dev/null +++ b/feature/settings/src/test/java/com/thesetox/settings/SettingsDataRepositoryTest.kt @@ -0,0 +1,37 @@ +package com.thesetox.settings + +import com.thesetox.datastore.AppDataStore +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +// Tests verifying the repository delegates to the underlying datastore. +class SettingsDataRepositoryTest { + private val dataStore: AppDataStore = mock() + private val repository = SettingsDataRepository(dataStore) + + @Test + fun `isDarkThemeEnabled delegates to datastore`() = + runTest { + // Arrange + whenever(dataStore.isDarkThemeEnabled()).thenReturn(false) + + // Act + repository.isDarkThemeEnabled() + + // Assert + verify(dataStore).isDarkThemeEnabled() + } + + @Test + fun `setDarkThemeEnabled delegates to datastore`() = + runTest { + // Act + repository.setDarkThemeEnabled(true) + + // Assert + verify(dataStore).setDarkThemeEnabled(true) + } +} diff --git a/feature/settings/src/test/java/com/thesetox/settings/SettingsUseCaseTest.kt b/feature/settings/src/test/java/com/thesetox/settings/SettingsUseCaseTest.kt new file mode 100644 index 0000000..af04a46 --- /dev/null +++ b/feature/settings/src/test/java/com/thesetox/settings/SettingsUseCaseTest.kt @@ -0,0 +1,38 @@ +package com.thesetox.settings + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SettingsUseCaseTest { + private val repository: SettingsRepository = mock() + + @Test + fun `saving preference updates repository`() = + runTest { + // Arrange + val setUseCase = SetDarkThemeEnabledUseCase(repository) + + // Act + setUseCase(true) + + // Assert + verify(repository).setDarkThemeEnabled(true) + } + + @Test + fun `getting preference delegates to repository`() = + runTest { + // Arrange + whenever(repository.isDarkThemeEnabled()).thenReturn(true) + val getUseCase = GetDarkThemeEnabledUseCase(repository) + + // Act + getUseCase() + + // Assert + verify(repository).isDarkThemeEnabled() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 13ad451..196f087 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,3 +34,4 @@ include(":feature:sync") include(":feature:exchange") include(":feature:balance") include(":feature:comission") +include(":feature:settings")