diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..61aa0a3 --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,21 @@ +name: Run tests +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Unit Tests + run: | + chmod +x gradlew + ./gradlew test --stacktrace diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 279ece1..7047c60 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,22 @@ android { } } + packaging { + resources { + excludes.add("/META-INF/{AL2.0,LGPL2.1}") + } + } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + kotlinOptions { + freeCompilerArgs += listOf( + "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + ) + } + } + namespace = "com.nativeapptemplate.nativeapptemplatefree" } @@ -138,6 +154,12 @@ dependencies { ksp(libs.hilt.compiler) debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.androidx.navigation.testing) + testImplementation(libs.hilt.android.testing) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) } diff --git a/app/src/main/assets/logged_in_shopkeeper.json b/app/src/main/assets/logged_in_shopkeeper.json new file mode 100644 index 0000000..bbf2519 --- /dev/null +++ b/app/src/main/assets/logged_in_shopkeeper.json @@ -0,0 +1,16 @@ +{ + "data": { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "type": "shopkeeper_sign_in", + "attributes": { + "account_id": "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + "personal_account_id": "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + "account_owner_id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "account_name": "Account1", + "email": "john@example.com", + "name": "John Smith", + "time_zone": "Tokyo", + "uid": "john@example.com" + } + } +} diff --git a/app/src/main/assets/permissions.json b/app/src/main/assets/permissions.json new file mode 100644 index 0000000..07fb0c7 --- /dev/null +++ b/app/src/main/assets/permissions.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + "type": "permission", + "attributes": { + "name": "update shops", + "tag": "update_shops", + "created_at": "2024-07-01T15:30:35.000Z", + "updated_at": "2024-07-01T15:30:35.000Z" + } + }, + { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1B", + "type": "permission", + "attributes": { + "name": "update organizations", + "tag": "update_organizations", + "created_at": "2024-07-01T15:30:35.000Z", + "updated_at": "2024-07-01T15:30:35.000Z" + } + }, + { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1C", + "type": "permission", + "attributes": { + "name": "invitation", + "tag": "invitation", + "created_at": "2024-07-01T15:30:35.000Z", + "updated_at": "2024-07-01T15:30:35.000Z" + } + } + ], + "meta": { + "android_app_version": 1, + "should_update_privacy": false, + "should_update_terms": false, + "shop_limit_count": 99 + } +} diff --git a/app/src/main/assets/shop.json b/app/src/main/assets/shop.json new file mode 100644 index 0000000..f6c73dd --- /dev/null +++ b/app/src/main/assets/shop.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1C", + "type": "shop", + "attributes": { + "name": "Shop1", + "description": "This is a Shop1", + "time_zone": "Tokyo" + }, + "meta": { + "limit_count": 96, + "created_shops_count": 3 + } + } +} diff --git a/app/src/main/assets/shops.json b/app/src/main/assets/shops.json new file mode 100644 index 0000000..3343315 --- /dev/null +++ b/app/src/main/assets/shops.json @@ -0,0 +1,43 @@ +{ + "data": [ + { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1C", + "type": "shop", + "attributes": { + "name": "Shop1", + "description": "This is a Shop1", + "time_zone": "Tokyo" + }, + "meta": { + "limit_count": 96, + "created_shops_count": 3 + } + }, + { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1D", + "type": "shop", + "attributes": { + "name": "Shop2", + "description": "This is a Shop2", + "time_zone": "Tokyo" + }, + "meta": { + "limit_count": 96, + "created_shops_count": 3 + } + }, + { + "id": "5712F2DF-DFC7-A3AA-66BC-191203654A1E", + "type": "shop", + "attributes": { + "name": "Shop3", + "description": "This is a Shop3", + "time_zone": "Tokyo" + }, + "meta": { + "limit_count": 96, + "created_shops_count": 3 + } + } + ] +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/DarkModeSettingsDialog.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/DarkModeSettingsDialog.kt index 5cdab88..10a3da9 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/DarkModeSettingsDialog.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/DarkModeSettingsDialog.kt @@ -1,10 +1,7 @@ -@file:Suppress("ktlint:standard:max-line-length") - package com.nativeapptemplate.nativeapptemplatefree.ui.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -109,7 +106,7 @@ fun DarkModeSettingsDialog( // [ColumnScope] is used for using the [ColumnScope.AnimatedVisibility] extension overload composable. @Composable -private fun ColumnScope.SettingsPanel( +private fun SettingsPanel( settings: UserEditableSettings, onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, ) { diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt index 6481779..975face 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsView.kt @@ -43,7 +43,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nativeapptemplate.nativeapptemplatefree.BuildConfig import com.nativeapptemplate.nativeapptemplatefree.NatConstants import com.nativeapptemplate.nativeapptemplatefree.R import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSourceTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSourceTest.kt new file mode 100644 index 0000000..1c1caa7 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NatPreferencesDataSourceTest.kt @@ -0,0 +1,76 @@ +package com.nativeapptemplate.nativeapptemplatefree.datastore + +import com.nativeapptemplate.nativeapptemplatefree.UserPreferences +import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class NatPreferencesDataSourceTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + + private lateinit var subject: NatPreferencesDataSource + + @Before + fun setup() { + subject = NatPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance())) + } + + @Test + fun isLoggedIn_isFalseByDefault() = testScope.runTest { + assertFalse(subject.userData.first().isLoggedIn) + } + + @Test + fun isLoggedIn_whenSettingShopkeeper_becomesTrue() = testScope.runTest { + assertFalse(subject.isLoggedIn().first()) + + subject.setShopkeeper(testInputLoggedInShopkeeper) + + assertTrue(subject.isLoggedIn().first()) + } +} + +private const val LOGGED_IN_SHOPKEEPER_TYPE = "shopkeeper_sign_in" +private const val LOGGED_IN_SHOPKEEPER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME = "Account1" +private const val LOGGED_IN_SHOPKEEPER_EMAIL = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_NAME = "John Smith" +private const val LOGGED_IN_SHOPKEEPER_TIME_ZONE = "Tokyo" +private const val LOGGED_IN_SHOPKEEPER_TOKEN = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_CLIENT = "Vd6GFW-9DaZrU2pzFd-Asa" +private const val LOGGED_IN_SHOPKEEPER_UID = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_EXPIRY = "1713165114" + +private val testInputLoggedInShopkeeperData = + Data( + id = LOGGED_IN_SHOPKEEPER_ID, + type = LOGGED_IN_SHOPKEEPER_TYPE, + attributes = Attributes( + accountId = LOGGED_IN_SHOPKEEPER_ACCOUNT_ID, + personalAccountId = LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID, + accountOwnerId = LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID, + accountName = LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME, + email = LOGGED_IN_SHOPKEEPER_EMAIL, + name = LOGGED_IN_SHOPKEEPER_NAME, + timeZone = LOGGED_IN_SHOPKEEPER_TIME_ZONE, + token = LOGGED_IN_SHOPKEEPER_TOKEN, + client = LOGGED_IN_SHOPKEEPER_CLIENT, + uid = LOGGED_IN_SHOPKEEPER_UID, + expiry = LOGGED_IN_SHOPKEEPER_EXPIRY, + ) + ) + +private val testInputLoggedInShopkeeper = LoggedInShopkeeper( + datum = testInputLoggedInShopkeeperData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializerTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializerTest.kt new file mode 100644 index 0000000..ca64543 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializerTest.kt @@ -0,0 +1,47 @@ +package com.nativeapptemplate.nativeapptemplatefree.datastore + +import androidx.datastore.core.CorruptionException +import com.nativeapptemplate.nativeapptemplatefree.userPreferences +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +class UserPreferencesSerializerTest { + private val userPreferencesSerializer = UserPreferencesSerializer() + + @Test + fun defaultUserPreferences_isEmpty() { + kotlin.test.assertEquals( + userPreferences { + // Default value + }, + userPreferencesSerializer.defaultValue, + ) + } + + @Test + fun writingAndReadingUserPreferences_outputsCorrectValue() = runTest { + val expectedUserPreferences = userPreferences { + isLoggedIn = true + } + + val outputStream = ByteArrayOutputStream() + + expectedUserPreferences.writeTo(outputStream) + + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + + val actualUserPreferences = userPreferencesSerializer.readFrom(inputStream) + + kotlin.test.assertEquals( + expectedUserPreferences, + actualUserPreferences, + ) + } + + @Test(expected = CorruptionException::class) + fun readingInvalidUserPreferences_throwsCorruptionException() = runTest { + userPreferencesSerializer.readFrom(ByteArrayInputStream(byteArrayOf(0))) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastoreTest/InMemoryDataStore.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastoreTest/InMemoryDataStore.kt new file mode 100644 index 0000000..6ae79e0 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastoreTest/InMemoryDataStore.kt @@ -0,0 +1,12 @@ +package com.nativeapptemplate.nativeapptemplatefree.datastoreTest + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.updateAndGet + +class InMemoryDataStore(initialValue: T) : DataStore { + override val data = MutableStateFlow(initialValue) + override suspend fun updateData( + transform: suspend (it: T) -> T, + ) = data.updateAndGet { transform(it) } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastoreTest/TestDataStoreModule.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastoreTest/TestDataStoreModule.kt new file mode 100644 index 0000000..f315066 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastoreTest/TestDataStoreModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nativeapptemplate.nativeapptemplatefree.datastoreTest + +import androidx.datastore.core.DataStore +import com.nativeapptemplate.nativeapptemplatefree.UserPreferences +import com.nativeapptemplate.nativeapptemplatefree.datastore.UserPreferencesSerializer +import com.nativeapptemplate.nativeapptemplatefree.di.modules.DataStoreModule +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DataStoreModule::class], +) +internal object TestDataStoreModule { + @Provides + @Singleton + fun providesUserPreferencesDataStore( + serializer: UserPreferencesSerializer, + ): DataStore = InMemoryDataStore(serializer.defaultValue) +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/DemoAssetManager.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/DemoAssetManager.kt new file mode 100644 index 0000000..2d2ce2a --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/DemoAssetManager.kt @@ -0,0 +1,8 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo + +import android.content.Context +import java.io.InputStream + +fun interface DemoAssetManager { + fun open(context : Context, fileName: String): InputStream +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/DemoAssetManagerImpl.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/DemoAssetManagerImpl.kt new file mode 100644 index 0000000..b4f7f88 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/DemoAssetManagerImpl.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nativeapptemplate.nativeapptemplatefree.demo + +import android.content.Context +import java.io.InputStream + + +internal object DemoAssetManagerImpl : DemoAssetManager { + override fun open(context : Context, fileName: String): InputStream = context.assets.open(fileName) +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoAccountPasswordRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoAccountPasswordRepository.kt new file mode 100644 index 0000000..5c37395 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoAccountPasswordRepository.kt @@ -0,0 +1,13 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.login + +import com.nativeapptemplate.nativeapptemplatefree.data.login.AccountPasswordRepository +import com.nativeapptemplate.nativeapptemplatefree.model.UpdatePasswordBody +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +class DemoAccountPasswordRepository @Inject constructor( +) : AccountPasswordRepository { + + override fun updateAccountPassword(updatePasswordBody: UpdatePasswordBody): Flow = MutableStateFlow(true) +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoAccountPasswordRepositoryTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoAccountPasswordRepositoryTest.kt new file mode 100644 index 0000000..3066c34 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoAccountPasswordRepositoryTest.kt @@ -0,0 +1,35 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.login + +import com.nativeapptemplate.nativeapptemplatefree.model.UpdatePasswordBody +import com.nativeapptemplate.nativeapptemplatefree.model.UpdatePasswordBodyDetail +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DemoAccountPasswordRepositoryTest { + private lateinit var subject: DemoAccountPasswordRepository + + @Before + fun setUp() { + subject = DemoAccountPasswordRepository() + } + + @Test + fun testUpdatePassword() = runTest { + kotlin.test.assertTrue( + subject.updateAccountPassword( + UpdatePasswordBody( + UpdatePasswordBodyDetail( + currentPassword = "password", + password = "updatingPassword", + passwordConfirmation = "updatingPassword", + ) + ) + ).first() + ) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt new file mode 100644 index 0000000..ac9a59a --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt @@ -0,0 +1,111 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.login + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManager +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.Login +import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.model.UserData +import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher +import com.nativeapptemplate.nativeapptemplatefree.network.NatDispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import javax.inject.Inject + +class DemoLoginRepository @Inject constructor( + @Dispatcher(NatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, + private val networkJson: Json, + private val assets: DemoAssetManager = DemoAssetManagerImpl, +) : LoginRepository { + private val _userData = MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) + override val userData: Flow = _userData.filterNotNull() + + private val loggedInShopkeeperFlow: Flow = flow { + val loggedInShopkeeper = getDataFromJsonFile(LOGGED_IN_SHOPKEEPER_ASSET) + emit(loggedInShopkeeper) + }.flowOn(ioDispatcher) + + private val permissionsFlow: Flow = flow { + val permissions = getDataFromJsonFile(PERMISSIONS_ASSET) + emit(permissions) + }.flowOn(ioDispatcher) + + override fun login(login: Login): Flow = loggedInShopkeeperFlow + + override fun logout(): Flow = MutableStateFlow(true) + + override fun getPermissions(): Flow = permissionsFlow + + override fun updateConfirmedPrivacyVersion(): Flow = MutableStateFlow(true) + + override fun updateConfirmedTermsVersion(): Flow = MutableStateFlow(true) + + override suspend fun setAccountId(accountId: String) { + } + + override suspend fun setShopkeeper(loggedInShopkeeper: LoggedInShopkeeper) { + } + + override suspend fun setShopkeeperForUpdate(loggedInShopkeeper: LoggedInShopkeeper) { + } + + override suspend fun setPermissions(permissions: Permissions) { + } + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + } + + override suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { + } + + override suspend fun setIsMyAccountDeleted(isMyAccountDeleted: Boolean) { + } + + override suspend fun setIsShopDeleted(isShopDeleted: Boolean) { + } + + override suspend fun clearUserPreferences() { + } + + override fun isLoggedIn(): Flow = MutableStateFlow(true) + + override fun shouldUpdateApp(): Flow = MutableStateFlow(true) + + override fun shouldUpdatePrivacy(): Flow = MutableStateFlow(true) + + override fun shouldUpdateTerms(): Flow = MutableStateFlow(true) + + override fun isEmailUpdated(): Flow = MutableStateFlow(true) + + override fun isMyAccountDeleted(): Flow = MutableStateFlow(true) + + override fun isShopDeleted(): Flow = MutableStateFlow(true) + + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun getDataFromJsonFile(fileName: String): T = + withContext(ioDispatcher) { + val context = ApplicationProvider.getApplicationContext() + assets.open(context, fileName).use { inputStream -> + networkJson.decodeFromStream(inputStream) + } + } + + companion object { + private const val LOGGED_IN_SHOPKEEPER_ASSET = "logged_in_shopkeeper.json" + private const val PERMISSIONS_ASSET = "permissions.json" + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepositoryTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepositoryTest.kt new file mode 100644 index 0000000..150da34 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepositoryTest.kt @@ -0,0 +1,118 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.login + +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Login +import com.nativeapptemplate.nativeapptemplatefree.model.Meta +import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DemoLoginRepositoryTest { + private lateinit var subject: DemoLoginRepository + private val testDispatcher = StandardTestDispatcher() + + private val loggedInShopkeeperData = Data( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + type = "shopkeeper_sign_in", + attributes = Attributes( + accountId = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + personalAccountId = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + accountOwnerId = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + accountName = "Account1", + email = "john@example.com", + name = "John Smith", + timeZone = "Tokyo", + uid = "john@example.com", + ), + ) + + private val permissionsData = Permissions( + datum = listOf( + Data( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + type = "permission", + attributes = Attributes( + name = "update shops", + tag = "update_shops", + createdAt = "2024-07-01T15:30:35.000Z", + updatedAt = "2024-07-01T15:30:35.000Z" + ), + ), + Data( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1B", + type = "permission", + attributes = Attributes( + name = "update organizations", + tag = "update_organizations", + createdAt = "2024-07-01T15:30:35.000Z", + updatedAt = "2024-07-01T15:30:35.000Z" + ), + ), + Data( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1C", + type = "permission", + attributes = Attributes( + name = "invitation", + tag = "invitation", + createdAt = "2024-07-01T15:30:35.000Z", + updatedAt = "2024-07-01T15:30:35.000Z" + ), + ), + ), + meta = Meta( + androidAppVersion = 1, + shouldUpdatePrivacy = false, + shouldUpdateTerms = false, + shopLimitCount = 99, + ) + ) + + @Before + fun setUp() { + subject = DemoLoginRepository( + ioDispatcher = testDispatcher, + networkJson = Json { ignoreUnknownKeys = true }, + assets = DemoAssetManagerImpl, + ) + } + + @Test + fun testDeserializationOfLoggedInShopkeeperFromLogin() = runTest(testDispatcher) { + kotlin.test.assertEquals( + loggedInShopkeeperData, + subject.login(Login(email = "john@example.com", password = "password")).first().datum, + ) + } + + @Test + fun testLogout() = runTest(testDispatcher) { + kotlin.test.assertTrue(subject.logout().first()) + } + + @Test + fun testDeserializationOfPermissions() = runTest(testDispatcher) { + kotlin.test.assertEquals( + permissionsData, + subject.getPermissions().first(), + ) + } + + @Test + fun testUpdateConfirmedPrivacyVersion() = runTest(testDispatcher) { + kotlin.test.assertTrue(subject.updateConfirmedPrivacyVersion().first()) + } + + @Test + fun testUpdateConfirmedTermsVersion() = runTest(testDispatcher) { + kotlin.test.assertTrue(subject.updateConfirmedTermsVersion().first()) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoSignUpRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoSignUpRepository.kt new file mode 100644 index 0000000..1fc7f1a --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoSignUpRepository.kt @@ -0,0 +1,58 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.login + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.nativeapptemplate.nativeapptemplatefree.data.login.SignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManager +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.SendConfirmation +import com.nativeapptemplate.nativeapptemplatefree.model.SendResetPassword +import com.nativeapptemplate.nativeapptemplatefree.model.SignUp +import com.nativeapptemplate.nativeapptemplatefree.model.SignUpForUpdate +import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher +import com.nativeapptemplate.nativeapptemplatefree.network.NatDispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import javax.inject.Inject + +class DemoSignUpRepository @Inject constructor( + @Dispatcher(NatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, + private val networkJson: Json, + private val assets: DemoAssetManager = DemoAssetManagerImpl, +) : SignUpRepository { + private val loggedInShopkeeperFlow: Flow = flow { + val loggedInShopkeeper = getDataFromJsonFile(LOGGED_IN_SHOPKEEPER_ASSET) + emit(loggedInShopkeeper) + }.flowOn(ioDispatcher) + + override fun signUp(signUp: SignUp): Flow = loggedInShopkeeperFlow + + override fun updateAccount(signUpForUpdate: SignUpForUpdate): Flow = loggedInShopkeeperFlow + + override fun deleteAccount(): Flow = MutableStateFlow(true) + + override fun sendResetPasswordInstruction(sendResetPassword: SendResetPassword): Flow = MutableStateFlow(true) + + override fun sendConfirmationInstruction(sendConfirmation: SendConfirmation): Flow = MutableStateFlow(true) + + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun getDataFromJsonFile(fileName: String): T = + withContext(ioDispatcher) { + val context = ApplicationProvider.getApplicationContext() + assets.open(context, fileName).use { inputStream -> + networkJson.decodeFromStream(inputStream) + } + } + + companion object { + private const val LOGGED_IN_SHOPKEEPER_ASSET = "logged_in_shopkeeper.json" + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoSignUpRepositoryTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoSignUpRepositoryTest.kt new file mode 100644 index 0000000..c872c83 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoSignUpRepositoryTest.kt @@ -0,0 +1,108 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.login + +import com.nativeapptemplate.nativeapptemplatefree.NatConstants +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.SendConfirmation +import com.nativeapptemplate.nativeapptemplatefree.model.SendResetPassword +import com.nativeapptemplate.nativeapptemplatefree.model.SignUp +import com.nativeapptemplate.nativeapptemplatefree.model.SignUpForUpdate +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DemoSignUpRepositoryTest { + private lateinit var subject: DemoSignUpRepository + private val testDispatcher = StandardTestDispatcher() + + private val loggedInShopkeeperData = Data( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + type = "shopkeeper_sign_in", + attributes = Attributes( + accountId = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + personalAccountId = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + accountOwnerId = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + accountName = "Account1", + email = "john@example.com", + name = "John Smith", + timeZone = "Tokyo", + uid = "john@example.com", + ), + ) + + @Before + fun setUp() { + subject = DemoSignUpRepository( + ioDispatcher = testDispatcher, + networkJson = Json { ignoreUnknownKeys = true }, + assets = DemoAssetManagerImpl, + ) + } + + @Test + fun testDeserializationOfLoggedInShopkeeperFromSignUp() = runTest(testDispatcher) { + kotlin.test.assertEquals( + LoggedInShopkeeper(datum = loggedInShopkeeperData), + subject.signUp( + SignUp( + name = "John Smith", + email = "john@example.com", + password = "password", + timeZone = "Tokyo", + currentPlatform = "android" + ), + ).first(), + ) + } + + @Test + fun testDeserializationOfLoggedInShopkeeperFromUpdatingAccount() = runTest(testDispatcher) { + kotlin.test.assertEquals( + LoggedInShopkeeper(datum = loggedInShopkeeperData), + subject.updateAccount( + SignUpForUpdate( + name = "John Smith", + email = "john@example.com", + timeZone = "Tokyo", + ), + ).first(), + ) + } + + @Test + fun testDeleteOfAccount() = runTest(testDispatcher) { + kotlin.test.assertTrue(subject.deleteAccount().first()) + } + + @Test + fun testSendResetPasswordInstruction() = runTest(testDispatcher) { + kotlin.test.assertTrue( + subject.sendResetPasswordInstruction( + SendResetPassword( + email = "john@example.com", + redirectUrl = SendResetPassword.redirectUrlString(NatConstants.baseUrlString()), + ) + ).first() + ) + } + + @Test + fun testSendConfirmationInstruction() = runTest(testDispatcher) { + kotlin.test.assertTrue( + subject.sendConfirmationInstruction( + SendConfirmation( + email = "john@example.com", + redirectUrl = SendConfirmation.redirectUrlString(NatConstants.baseUrlString()), + ) + ).first() + ) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepository.kt new file mode 100644 index 0000000..f259100 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepository.kt @@ -0,0 +1,63 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.shop + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManager +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.model.ShopBody +import com.nativeapptemplate.nativeapptemplatefree.model.ShopUpdateBody +import com.nativeapptemplate.nativeapptemplatefree.model.Shops +import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher +import com.nativeapptemplate.nativeapptemplatefree.network.NatDispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import javax.inject.Inject + +class DemoShopRepository @Inject constructor( + @Dispatcher(NatDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, + private val networkJson: Json, + private val assets: DemoAssetManager = DemoAssetManagerImpl, +) : ShopRepository { + private val shopsFlow: Flow = flow { + val shops = getDataFromJsonFile(SHOPS_ASSET) + emit(shops) + }.flowOn(ioDispatcher) + + private val shopFlow: Flow = flow { + val shop = getDataFromJsonFile(SHOP_ASSET) + emit(shop) + }.flowOn(ioDispatcher) + + override fun getShops(): Flow = shopsFlow + + override fun getShop(id: String): Flow = shopFlow + + override fun createShop(shopBody: ShopBody): Flow = shopFlow + + override fun updateShop(id: String, shopUpdateBody: ShopUpdateBody): Flow = shopFlow + + override fun deleteShop(id: String): Flow = MutableStateFlow(true) + + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun getDataFromJsonFile(fileName: String): T = + withContext(ioDispatcher) { + val context = ApplicationProvider.getApplicationContext() + assets.open(context, fileName).use { inputStream -> + networkJson.decodeFromStream(inputStream) + } + } + + companion object { + private const val SHOPS_ASSET = "shops.json" + private const val SHOP_ASSET = "shop.json" + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepositoryTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepositoryTest.kt new file mode 100644 index 0000000..664d553 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/shop/DemoShopRepositoryTest.kt @@ -0,0 +1,102 @@ +package com.nativeapptemplate.nativeapptemplatefree.demo.shop + +import com.nativeapptemplate.nativeapptemplatefree.demo.DemoAssetManagerImpl +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Meta +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.model.ShopBody +import com.nativeapptemplate.nativeapptemplatefree.model.ShopBodyDetail +import com.nativeapptemplate.nativeapptemplatefree.model.ShopUpdateBody +import com.nativeapptemplate.nativeapptemplatefree.model.ShopUpdateBodyDetail +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DemoShopRepositoryTest { + private lateinit var subject: DemoShopRepository + private val testDispatcher = StandardTestDispatcher() + + private val shopData = Data( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1C", + type = "shop", + attributes = Attributes( + name = "Shop1", + description = "This is a Shop1", + timeZone = "Tokyo", + ), + meta = Meta( + limitCount = 96, + createdShopsCount = 3 + ), + ) + + @Before + fun setUp() { + subject = DemoShopRepository( + ioDispatcher = testDispatcher, + networkJson = Json { ignoreUnknownKeys = true }, + assets = DemoAssetManagerImpl, + ) + } + + @Test + fun testDeserializationOfShops() = runTest(testDispatcher) { + kotlin.test.assertEquals( + shopData, + subject.getShops().first().datum.first(), + ) + } + + @Test + fun testDeserializationOfShop() = runTest(testDispatcher) { + kotlin.test.assertEquals( + Shop(datum = shopData), + subject.getShop(id = "5712F2DF-DFC7-A3AA-66BC-191203654A1C").first(), + ) + } + + @Test + fun testDeserializationOfShopFromCreating() = runTest(testDispatcher) { + kotlin.test.assertEquals( + Shop(datum = shopData), + subject.createShop( + ShopBody( + shopBodyDetail = ShopBodyDetail( + name = "Shop1", + description = "This is a Shop1", + timeZone = "Tokyo", + ) + ) + ).first(), + ) + } + + @Test + fun testDeserializationOfShopFromUpdating() = runTest(testDispatcher) { + kotlin.test.assertEquals( + Shop(datum = shopData), + subject.updateShop( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1C", + shopUpdateBody = ShopUpdateBody( + shopUpdateBodyDetail = ShopUpdateBodyDetail( + name = "Shop1", + description = "This is a Shop1", + timeZone = "Tokyo", + ), + ), + ).first(), + ) + } + + @Test + fun testDeleteOfShop() = runTest(testDispatcher) { + kotlin.test.assertTrue(subject.deleteShop("5712F2DF-DFC7-A3AA-66BC-191203654A1C").first()) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/di/TestDispatcherModule.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/di/TestDispatcherModule.kt new file mode 100644 index 0000000..72b6075 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/di/TestDispatcherModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nativeapptemplate.nativeapptemplatefree.testing.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object TestDispatcherModule { + @Provides + @Singleton + fun providesTestDispatcher(): TestDispatcher = UnconfinedTestDispatcher() +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/di/TestDispatchersModule.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/di/TestDispatchersModule.kt new file mode 100644 index 0000000..a1b9c25 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/di/TestDispatchersModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nativeapptemplate.nativeapptemplatefree.testing.di + +import com.nativeapptemplate.nativeapptemplatefree.di.modules.DispatchersModule +import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher +import com.nativeapptemplate.nativeapptemplatefree.network.NatDispatchers +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.TestDispatcher + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DispatchersModule::class], +) +internal object TestDispatchersModule { + @Provides + @Dispatcher(NatDispatchers.IO) + fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher + + @Provides + @Dispatcher(NatDispatchers.Default) + fun providesDefaultDispatcher( + testDispatcher: TestDispatcher, + ): CoroutineDispatcher = testDispatcher +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestAccountPasswordRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestAccountPasswordRepository.kt new file mode 100644 index 0000000..d1d56c8 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestAccountPasswordRepository.kt @@ -0,0 +1,12 @@ +package com.nativeapptemplate.nativeapptemplatefree.testing.repository + +import com.nativeapptemplate.nativeapptemplatefree.data.login.AccountPasswordRepository +import com.nativeapptemplate.nativeapptemplatefree.model.UpdatePasswordBody +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class TestAccountPasswordRepository : AccountPasswordRepository { + override fun updateAccountPassword( + updatePasswordBody: UpdatePasswordBody + ): Flow = MutableStateFlow(true) +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt new file mode 100644 index 0000000..0cbe77b --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt @@ -0,0 +1,146 @@ +package com.nativeapptemplate.nativeapptemplatefree.testing.repository + +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.Login +import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.model.UserData +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull + +val emptyUserData = UserData( +) + +class TestLoginRepository : LoginRepository { + private val loggedInShopkeeperFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) + + private val permissionsFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) + + private val isLoggedInReturnFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) + + private val _userData = MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) + + private val currentUserData get() = _userData.replayCache.firstOrNull() ?: emptyUserData + + override val userData: Flow = _userData.filterNotNull() + + override fun login(login: Login): Flow = loggedInShopkeeperFlow + + override fun logout(): Flow = MutableStateFlow(true) + + override fun getPermissions(): Flow = permissionsFlow + + override fun updateConfirmedPrivacyVersion(): Flow = MutableStateFlow(true) + + override fun updateConfirmedTermsVersion(): Flow = MutableStateFlow(true) + + override suspend fun setAccountId(accountId: String) { + } + + override suspend fun setShopkeeper(loggedInShopkeeper: LoggedInShopkeeper) { + currentUserData.let { current -> + _userData.tryEmit( + current.copy( + id = loggedInShopkeeper.getId()!!, + accountId = loggedInShopkeeper.getAccountId()!!, + personalAccountId = loggedInShopkeeper.getPersonalAccountId()!!, + accountOwnerId = loggedInShopkeeper.getAccountOwnerId()!!, + accountName = loggedInShopkeeper.getAccountName()!!, + email = loggedInShopkeeper.getEmail()!!, + name = loggedInShopkeeper.getName()!!, + timeZone = loggedInShopkeeper.getTimeZone()!!, + token = loggedInShopkeeper.getToken()!!, + client = loggedInShopkeeper.getClient()!!, + uid = loggedInShopkeeper.getUID()!!, + expiry = loggedInShopkeeper.getExpiry()!!, + isLoggedIn = true, + ) + ) + } + } + + override suspend fun setShopkeeperForUpdate(loggedInShopkeeper: LoggedInShopkeeper) { + currentUserData.let { current -> + _userData.tryEmit( + current.copy( + email = loggedInShopkeeper.getEmail()!!, + name = loggedInShopkeeper.getName()!!, + timeZone = loggedInShopkeeper.getTimeZone()!!, + uid = loggedInShopkeeper.getUID()!!, + ) + ) + } + } + override suspend fun setPermissions(permissions: Permissions) { + currentUserData.let { current -> + _userData.tryEmit( + current.copy( + androidAppVersion = permissions.getAndroidAppVersion()!!, + shouldUpdatePrivacy = permissions.getShouldUpdatePrivacy()!!, + shouldUpdateTerms = permissions.getShouldUpdateTerms()!!, + shopLimitCount = permissions.getShopLimitCount()!!, + ) + ) + } + } + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(darkThemeConfig = darkThemeConfig)) + } + } + + override suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(isEmailUpdated = isEmailUpdated)) + } + } + + override suspend fun setIsMyAccountDeleted(isMyAccountDeleted: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(isMyAccountDeleted = isMyAccountDeleted)) + } + } + + override suspend fun setIsShopDeleted(isShopDeleted: Boolean) { + } + + override suspend fun clearUserPreferences() { + } + + override fun isLoggedIn(): Flow = isLoggedInReturnFlow + + override fun shouldUpdateApp(): Flow = MutableStateFlow(false) + + override fun shouldUpdatePrivacy(): Flow = MutableStateFlow(false) + + override fun shouldUpdateTerms(): Flow = MutableStateFlow(false) + + override fun isEmailUpdated(): Flow = MutableStateFlow(false) + + override fun isMyAccountDeleted(): Flow = MutableStateFlow(false) + + override fun isShopDeleted(): Flow = MutableStateFlow(false) + + /** + * A test-only API. + */ + fun sendUserData(userData: UserData) { + _userData.tryEmit(userData) + } + + fun sendLoggedInShopkeeper(loggedInShopkeeper: LoggedInShopkeeper) { + loggedInShopkeeperFlow.tryEmit(loggedInShopkeeper) + } + + fun sendPermissions(permissions: Permissions) { + permissionsFlow.tryEmit(permissions) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestShopRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestShopRepository.kt new file mode 100644 index 0000000..25b1b63 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestShopRepository.kt @@ -0,0 +1,40 @@ +package com.nativeapptemplate.nativeapptemplatefree.testing.repository + +import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.model.ShopBody +import com.nativeapptemplate.nativeapptemplatefree.model.ShopUpdateBody +import com.nativeapptemplate.nativeapptemplatefree.model.Shops +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +class TestShopRepository : ShopRepository { + private val shopsFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private val shopFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override fun getShops(): Flow = shopsFlow + + override fun getShop(id: String): Flow = shopFlow + + override fun createShop(shopBody: ShopBody): Flow = shopFlow + + override fun updateShop(id: String, shopUpdateBody: ShopUpdateBody): Flow = shopFlow + + override fun deleteShop(id: String): Flow = MutableStateFlow(true) + + /** + * A test-only API. + */ + fun sendShops(shops: Shops) { + shopsFlow.tryEmit(shops) + } + + fun sendShop(shop: Shop) { + shopFlow.tryEmit(shop) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestSignUpRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestSignUpRepository.kt new file mode 100644 index 0000000..5c11d19 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestSignUpRepository.kt @@ -0,0 +1,40 @@ +package com.nativeapptemplate.nativeapptemplatefree.testing.repository + +import com.nativeapptemplate.nativeapptemplatefree.data.login.SignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.SendConfirmation +import com.nativeapptemplate.nativeapptemplatefree.model.SendResetPassword +import com.nativeapptemplate.nativeapptemplatefree.model.SignUp +import com.nativeapptemplate.nativeapptemplatefree.model.SignUpForUpdate +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +class TestSignUpRepository : SignUpRepository { + private val loggedInShopkeeperFlow: MutableSharedFlow = + MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) + + override fun signUp(signUp: SignUp): Flow = loggedInShopkeeperFlow + + override fun updateAccount( + signUpForUpdate: SignUpForUpdate + ): Flow = loggedInShopkeeperFlow + + override fun deleteAccount(): Flow = MutableStateFlow(true) + + override fun sendResetPasswordInstruction( + sendResetPassword: SendResetPassword + ): Flow = MutableStateFlow(true) + + override fun sendConfirmationInstruction( + sendConfirmation: SendConfirmation + ): Flow = MutableStateFlow(true) + + /** + * A test-only API. + */ + fun sendLoggedInShopkeeper(loggedInShopkeeper: LoggedInShopkeeper) { + loggedInShopkeeperFlow.tryEmit(loggedInShopkeeper) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/util/MainDispatcherRule.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/util/MainDispatcherRule.kt new file mode 100644 index 0000000..99812b0 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/util/MainDispatcherRule.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nativeapptemplate.nativeapptemplatefree.testing.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestRule +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher] + * for the duration of the test. + */ +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) = Dispatchers.setMain(testDispatcher) + + override fun finished(description: Description) = Dispatchers.resetMain() +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/AcceptPrivacyViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/AcceptPrivacyViewModelTest.kt new file mode 100644 index 0000000..b1a240f --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/AcceptPrivacyViewModelTest.kt @@ -0,0 +1,45 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AcceptPrivacyViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: AcceptPrivacyViewModel + + @Before + fun setUp() { + viewModel = AcceptPrivacyViewModel( + loginRepository = loginRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsUpdated_whenUpdatingConfirmedPrivacyVersion_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + viewModel.updateConfirmedPrivacyVersion() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isUpdated) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/AcceptTermsViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/AcceptTermsViewModelTest.kt new file mode 100644 index 0000000..3b3c979 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/AcceptTermsViewModelTest.kt @@ -0,0 +1,45 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AcceptTermsViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: AcceptTermsViewModel + + @Before + fun setUp() { + viewModel = AcceptTermsViewModel( + loginRepository = loginRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsUpdated_whenUpdatingConfirmedTermsVersion_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + viewModel.updateConfirmedTermsVersion() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isUpdated) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/ForgotPasswordViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/ForgotPasswordViewModelTest.kt new file mode 100644 index 0000000..3490dea --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/ForgotPasswordViewModelTest.kt @@ -0,0 +1,74 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `String.validateEmail` which has a dependency on `android.util.Patterns.EMAIL_ADDRESS`. + */ +@RunWith(RobolectricTestRunner::class) +class ForgotPasswordViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val signUpRepository = TestSignUpRepository() + + private lateinit var viewModel: ForgotPasswordViewModel + + @Before + fun setUp() { + viewModel = ForgotPasswordViewModel( + signUpRepository = signUpRepository, + ) + } + + @Test + fun stateIsInitiallyNotLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsSent_whenSendingResetPasswordInstructions_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val email = "john@example.com" + viewModel.updateEmail(email) + + assertEquals(viewModel.uiState.value.email, email) + assertFalse(viewModel.hasInvalidData()) + + viewModel.sendMeResetPasswordInstructions() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isSent) + } + + @Test + fun blankEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun wrongFormatEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("wrongFormatEmail@com") + + assertTrue(viewModel.hasInvalidDataEmail()) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/OnboardingViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/OnboardingViewModelTest.kt new file mode 100644 index 0000000..839f352 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/OnboardingViewModelTest.kt @@ -0,0 +1,18 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.R +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +class OnboardingViewModelTest { + @Test + fun onboardingDescription_isValid() = runTest { + assertEquals(OnboardingViewModel.onboardingDescription(0), R.string.onboarding_description1) + } + + @Test + fun onboardingImageId_isValid() = runTest { + assertEquals(OnboardingViewModel.onboardingImageId(0), R.drawable.ic_overview1) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/ResendConfirmationInstructionsViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/ResendConfirmationInstructionsViewModelTest.kt new file mode 100644 index 0000000..80e55aa --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/ResendConfirmationInstructionsViewModelTest.kt @@ -0,0 +1,74 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `String.validateEmail` which has a dependency on `android.util.Patterns.EMAIL_ADDRESS`. + */ +@RunWith(RobolectricTestRunner::class) +class ResendConfirmationInstructionsViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val signUpRepository = TestSignUpRepository() + + private lateinit var viewModel: ResendConfirmationInstructionsViewModel + + @Before + fun setUp() { + viewModel = ResendConfirmationInstructionsViewModel( + signUpRepository = signUpRepository, + ) + } + + @Test + fun stateIsInitiallyNotLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsSent_whenSendingPasswordInstructions_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val email = "john@example.com" + viewModel.updateEmail(email) + + assertEquals(viewModel.uiState.value.email, email) + assertFalse(viewModel.hasInvalidData()) + + viewModel.sendMeResetPasswordInstructions() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isSent) + } + + @Test + fun blankEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun wrongFormatEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("wrongFormatEmail@com") + + assertTrue(viewModel.hasInvalidDataEmail()) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt new file mode 100644 index 0000000..74876cc --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt @@ -0,0 +1,210 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.Meta +import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `String.validateEmail` which has a dependency on `android.util.Patterns.EMAIL_ADDRESS`. + */ +@RunWith(RobolectricTestRunner::class) +class SignInEmailAndPasswordViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: SignInEmailAndPasswordViewModel + + @Before + fun setUp() { + viewModel = SignInEmailAndPasswordViewModel( + loginRepository = loginRepository, + ) + } + + @Test + fun stateIsInitiallyNotLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun shopkeeperAndPermission_whenLoggingIn_isSavedInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val email = testInputLoggedInShopkeeper.getEmail()!! + + viewModel.updateEmail(email) + viewModel.updatePassword(testInputPassword) + + assertEquals(viewModel.uiState.value.email, email) + assertEquals(viewModel.uiState.value.password, testInputPassword) + assertFalse(viewModel.hasInvalidData()) + + loginRepository.sendLoggedInShopkeeper(testInputLoggedInShopkeeper) + loginRepository.sendPermissions(testInputPermissions) + viewModel.login() + + val userData = loginRepository.userData.first() + + assertEquals(userData.androidAppVersion, testInputPermissions.getAndroidAppVersion()!!) + assertEquals(userData.shouldUpdatePrivacy, testInputPermissions.getShouldUpdatePrivacy()!!) + assertEquals(userData.shouldUpdateTerms, testInputPermissions.getShouldUpdateTerms()!!) + assertEquals(userData.shopLimitCount, testInputPermissions.getShopLimitCount()!!) + + val uiStateValue = viewModel.uiState.value + assertEquals(uiStateValue.loggedInShopkeeper, testInputLoggedInShopkeeper) + assertFalse(uiStateValue.isLoading) + } + + @Test + fun blankEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun wrongFormatEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("wrongFormatEmail@com") + + assertTrue(viewModel.hasInvalidDataEmail()) + } + + @Test + fun blankPassword_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updatePassword("") + + assertTrue(viewModel.hasInvalidDataEmail()) + } + + @Test + fun passwordWithIncorrectLength_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updatePassword("1234567") + + assertTrue(viewModel.hasInvalidDataEmail()) + } +} + +private const val LOGGED_IN_SHOPKEEPER_TYPE = "shopkeeper_sign_in" +private const val LOGGED_IN_SHOPKEEPER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME = "Account1" +private const val LOGGED_IN_SHOPKEEPER_EMAIL = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_NAME = "John Smith" +private const val LOGGED_IN_SHOPKEEPER_TIME_ZONE = "Tokyo" +private const val LOGGED_IN_SHOPKEEPER_TOKEN = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_CLIENT = "Vd6GFW-9DaZrU2pzFd-Asa" +private const val LOGGED_IN_SHOPKEEPER_UID = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_EXPIRY = "1713165114" + +private val testInputLoggedInShopkeeperData = + Data( + id = LOGGED_IN_SHOPKEEPER_ID, + type = LOGGED_IN_SHOPKEEPER_TYPE, + attributes = Attributes( + accountId = LOGGED_IN_SHOPKEEPER_ACCOUNT_ID, + personalAccountId = LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID, + accountOwnerId = LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID, + accountName = LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME, + email = LOGGED_IN_SHOPKEEPER_EMAIL, + name = LOGGED_IN_SHOPKEEPER_NAME, + timeZone = LOGGED_IN_SHOPKEEPER_TIME_ZONE, + token = LOGGED_IN_SHOPKEEPER_TOKEN, + client = LOGGED_IN_SHOPKEEPER_CLIENT, + uid = LOGGED_IN_SHOPKEEPER_UID, + expiry = LOGGED_IN_SHOPKEEPER_EXPIRY, + ) + ) + +private val testInputLoggedInShopkeeper = LoggedInShopkeeper( + datum = testInputLoggedInShopkeeperData, +) + +private const val PERMISSION_TYPE = "permission" +private const val PERMISSION_1_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val PERMISSION_2_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1B" +private const val PERMISSION_3_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1C" +private const val PERMISSION_1_NAME = "update shops" +private const val PERMISSION_2_NAME = "update organizations" +private const val PERMISSION_3_NAME = "invitation" +private const val PERMISSION_1_TAG = "update_shops" +private const val PERMISSION_2_TAG = "update_organizations" +private const val PERMISSION_3_TAG = "invitation" +private const val PERMISSION_CREATED_AT = "2024-07-01T15:30:35.000Z" +private const val PERMISSION_UPDATED_AT = "2024-07-01T15:30:35.000Z" +private const val PERMISSION_ANDROID_APP_VERSION = 1 +private const val PERMISSION_SHOULD_UPDATE_PRIVACY = false +private const val PERMISSION_SHOULD_UPDATE_TERMS = false +private const val PERMISSION_SHOP_LIMIT_COUNT = 99 + +private val testInputPermissionsData = listOf( + Data( + id = PERMISSION_1_ID, + type = PERMISSION_TYPE, + attributes = Attributes( + name = PERMISSION_1_NAME, + tag = PERMISSION_1_TAG, + createdAt = PERMISSION_CREATED_AT, + updatedAt = PERMISSION_UPDATED_AT + ) + ), + Data( + id = PERMISSION_2_ID, + type = PERMISSION_TYPE, + attributes = Attributes( + name = PERMISSION_2_NAME, + tag = PERMISSION_2_TAG, + createdAt = PERMISSION_CREATED_AT, + updatedAt = PERMISSION_UPDATED_AT + ) + ), + Data( + id = PERMISSION_3_ID, + type = PERMISSION_TYPE, + attributes = Attributes( + name = PERMISSION_3_NAME, + tag = PERMISSION_3_TAG, + createdAt = PERMISSION_CREATED_AT, + updatedAt = PERMISSION_UPDATED_AT + ) + ), +) + +private val testInputPermissions = Permissions( + datum = testInputPermissionsData, + meta = Meta( + androidAppVersion = PERMISSION_ANDROID_APP_VERSION, + shouldUpdatePrivacy = PERMISSION_SHOULD_UPDATE_PRIVACY, + shouldUpdateTerms = PERMISSION_SHOULD_UPDATE_TERMS, + shopLimitCount = PERMISSION_SHOP_LIMIT_COUNT, + ) +) + +private const val testInputPassword = "password" diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignUpViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignUpViewModelTest.kt new file mode 100644 index 0000000..30d0f19 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignUpViewModelTest.kt @@ -0,0 +1,153 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `String.validateEmail` which has a dependency on `android.util.Patterns.EMAIL_ADDRESS`. + */ +@RunWith(RobolectricTestRunner::class) +class SignUpViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val signUpRepository = TestSignUpRepository() + + private lateinit var viewModel: SignUpViewModel + + @Before + fun setUp() { + viewModel = SignUpViewModel( + signUpRepository = signUpRepository, + ) + } + + @Test + fun stateIsInitiallyNotLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsCreated_whenSigningUp_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val name = testInputLoggedInShopkeeper.getName()!! + val email = testInputLoggedInShopkeeper.getEmail()!! + val timeZone = testInputLoggedInShopkeeper.getTimeZone()!! + + viewModel.updateName(name) + viewModel.updateEmail(email) + viewModel.updatePassword(testInputPassword) + viewModel.updateTimeZone(timeZone) + + assertEquals(viewModel.uiState.value.name, name) + assertEquals(viewModel.uiState.value.email, email) + assertEquals(viewModel.uiState.value.password, testInputPassword) + assertEquals(viewModel.uiState.value.timeZone, timeZone) + assertFalse(viewModel.hasInvalidData()) + + signUpRepository.sendLoggedInShopkeeper(testInputLoggedInShopkeeper) + viewModel.createShopkeeper() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isCreated) + } + + @Test + fun blankName_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateName("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun blankEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun wrongFormatEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("wrongFormatEmail@com") + + assertTrue(viewModel.hasInvalidDataEmail()) + } + + @Test + fun blankPassword_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updatePassword("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun passwordWithIncorrectLength_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updatePassword("1234567") + + assertTrue(viewModel.hasInvalidDataEmail()) + } +} + +private const val LOGGED_IN_SHOPKEEPER_TYPE = "shopkeeper_sign_in" +private const val LOGGED_IN_SHOPKEEPER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME = "Account1" +private const val LOGGED_IN_SHOPKEEPER_EMAIL = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_NAME = "John Smith" +private const val LOGGED_IN_SHOPKEEPER_TIME_ZONE = "Tokyo" +private const val LOGGED_IN_SHOPKEEPER_TOKEN = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_CLIENT = "Vd6GFW-9DaZrU2pzFd-Asa" +private const val LOGGED_IN_SHOPKEEPER_UID = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_EXPIRY = "1713165114" + +private val testInputLoggedInShopkeeperData = + Data( + id = LOGGED_IN_SHOPKEEPER_ID, + type = LOGGED_IN_SHOPKEEPER_TYPE, + attributes = Attributes( + accountId = LOGGED_IN_SHOPKEEPER_ACCOUNT_ID, + personalAccountId = LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID, + accountOwnerId = LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID, + accountName = LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME, + email = LOGGED_IN_SHOPKEEPER_EMAIL, + name = LOGGED_IN_SHOPKEEPER_NAME, + timeZone = LOGGED_IN_SHOPKEEPER_TIME_ZONE, + token = LOGGED_IN_SHOPKEEPER_TOKEN, + client = LOGGED_IN_SHOPKEEPER_CLIENT, + uid = LOGGED_IN_SHOPKEEPER_UID, + expiry = LOGGED_IN_SHOPKEEPER_EXPIRY, + ) + ) + +private val testInputLoggedInShopkeeper = LoggedInShopkeeper( + datum = testInputLoggedInShopkeeperData, +) + +private const val testInputPassword = "password" diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/DarkModeSettingsViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/DarkModeSettingsViewModelTest.kt new file mode 100644 index 0000000..7f63f31 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/DarkModeSettingsViewModelTest.kt @@ -0,0 +1,66 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.settings + +import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class DarkModeSettingsViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: DarkModeSettingsViewModel + + @Before + fun setUp() { + viewModel = DarkModeSettingsViewModel( + loginRepository = loginRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertEquals(DarkModeSettingsUiState.Loading, viewModel.darkModeSettingsUiState.value) + } + + @Test + fun stateIsSuccessAfterUserDataLoaded() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.darkModeSettingsUiState.collect() } + + loginRepository.setDarkThemeConfig(DarkThemeConfig.DARK) + + assertEquals( + DarkModeSettingsUiState.Success( + UserEditableSettings( + darkThemeConfig = DarkThemeConfig.DARK, + ), + ), + viewModel.darkModeSettingsUiState.value, + ) + } + + @Test + fun darkThemeConfig_whenUpdatingDarkThemeConfig_isSavedInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.darkModeSettingsUiState.collect() } + + viewModel.updateDarkThemeConfig(DarkThemeConfig.LIGHT) + + assertEquals( + DarkModeSettingsUiState.Success( + UserEditableSettings( + darkThemeConfig = DarkThemeConfig.LIGHT, + ), + ), + viewModel.darkModeSettingsUiState.value, + ) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/PasswordEditViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/PasswordEditViewModelTest.kt new file mode 100644 index 0000000..fa50abf --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/PasswordEditViewModelTest.kt @@ -0,0 +1,91 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.settings + +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAccountPasswordRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class PasswordEditViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val accountPasswordRepository = TestAccountPasswordRepository() + + private lateinit var viewModel: PasswordEditViewModel + + @Before + fun setUp() { + viewModel = PasswordEditViewModel( + accountPasswordRepository = accountPasswordRepository, + ) + } + + @Test + fun stateIsInitiallyNotLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsUpdated_whenUpdatingPassword_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateCurrentPassword(testInputCurrentPassword) + viewModel.updatePassword(testInputNewPassword) + viewModel.updatePasswordConfirmation(testInputNewPassword) + + assertEquals(viewModel.uiState.value.currentPassword, testInputCurrentPassword) + assertEquals(viewModel.uiState.value.password, testInputNewPassword) + assertEquals(viewModel.uiState.value.passwordConfirmation, testInputNewPassword) + assertFalse(viewModel.hasInvalidData()) + + viewModel.updatePassword() + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isUpdated) + } + + @Test + fun blankPassword_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updatePassword("") + + assertTrue(viewModel.hasInvalidDataPassword()) + } + + @Test + fun blankCurrentPassword_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateCurrentPassword("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun blankPasswordConfirmation_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updatePasswordConfirmation("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun passwordWithIncorrectLength_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updatePassword("1234567") + + assertTrue(viewModel.hasInvalidDataPassword()) + } +} + +private const val testInputCurrentPassword = "password" +private const val testInputNewPassword = "newPassword" \ No newline at end of file diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000..48f2077 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsViewModelTest.kt @@ -0,0 +1,77 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.settings + +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class SettingsViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: SettingsViewModel + + @Before + fun setUp() { + viewModel = SettingsViewModel( + loginRepository = loginRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateUserData_whenSuccess_matchesUserDataFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val userDataFromRepository = loginRepository.userData.first() + assertEquals(userDataFromRepository, uiStateValue.userData) + } + + @Test + fun stateIsLoading_whenLoggingOut_becomesFalse() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + + viewModel.reload() + viewModel.logout() + + val uiStateValue = viewModel.uiState.value + assertFalse(uiStateValue.isLoading) + } + + @Test + fun stateMessage_whenUpdatingMessage_becomesNewMessage() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + + viewModel.reload() + val newMessage = "New Message" + viewModel.updateMessage(newMessage) + + val uiStateValue = viewModel.uiState.value + assertEquals(uiStateValue.message, newMessage) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt new file mode 100644 index 0000000..099a5d7 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt @@ -0,0 +1,262 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.settings + +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `String.validateEmail` which has a dependency on `android.util.Patterns.EMAIL_ADDRESS`. + */ +@RunWith(RobolectricTestRunner::class) +class ShopkeeperEditViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + private val signUpRepository = TestSignUpRepository() + + private lateinit var viewModel: ShopkeeperEditViewModel + + @Before + fun setUp() { + viewModel = ShopkeeperEditViewModel( + loginRepository = loginRepository, + signUpRepository = signUpRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateUserData_whenSuccess_matchesUserDataFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(emptyUserData) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val userDataFromRepository = loginRepository.userData.first() + assertEquals(userDataFromRepository, uiStateValue.userData) + } + + @Test + fun shopkeeper_whenUpdatingShopkeeper_isSavedInPreference() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val userData = emptyUserData.copy( + name = testInputLoggedInShopkeeper.getName()!!, + email = testInputLoggedInShopkeeper.getEmail()!!, + timeZone = testInputLoggedInShopkeeper.getTimeZone()!!, + ) + + val newTestInputLoggedInShopkeeper = LoggedInShopkeeper( + datum = Data( + id = LOGGED_IN_SHOPKEEPER_ID, + type = LOGGED_IN_SHOPKEEPER_TYPE, + attributes = testInputLoggedInShopkeeper.datum!!.attributes!!.copy( + name = testInputNewName, + timeZone = testInputNewTimeZone + ) + ) + ) + + loginRepository.sendUserData(userData) + signUpRepository.sendLoggedInShopkeeper(newTestInputLoggedInShopkeeper) + + viewModel.reload() + viewModel.updateName(testInputNewName) + viewModel.updateTimeZone(testInputNewTimeZone) + + assertEquals(viewModel.uiState.value.name, testInputNewName) + assertEquals(viewModel.uiState.value.timeZone, testInputNewTimeZone) + assertFalse(viewModel.hasInvalidData()) + + viewModel.updateShopkeeper() + + val userDataFromLoginRepository = loginRepository.userData.first() + assertEquals(userDataFromLoginRepository.name, testInputNewName) + assertEquals(userDataFromLoginRepository.timeZone, testInputNewTimeZone) + assertFalse(userDataFromLoginRepository.isEmailUpdated) + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isUpdated) + assertFalse(uiStateValue.isEmailUpdated) + assertFalse(uiStateValue.isLoading) + } + + @Test + fun stateIsEmailUpdated_whenUpdatingShopkeeperEmail_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val userData = emptyUserData.copy( + name = testInputLoggedInShopkeeper.getName()!!, + email = testInputLoggedInShopkeeper.getEmail()!!, + timeZone = testInputLoggedInShopkeeper.getTimeZone()!!, + ) + + val newTestInputLoggedInShopkeeper = LoggedInShopkeeper( + datum = Data( + id = LOGGED_IN_SHOPKEEPER_ID, + type = LOGGED_IN_SHOPKEEPER_TYPE, + attributes = testInputLoggedInShopkeeper.datum!!.attributes!!.copy( + name = testInputNewName, + email = testInputNewEmail, + timeZone = testInputNewTimeZone + ) + ) + ) + + loginRepository.sendUserData(userData) + signUpRepository.sendLoggedInShopkeeper(newTestInputLoggedInShopkeeper) + + viewModel.reload() + viewModel.updateName(testInputNewName) + viewModel.updateEmail(testInputNewEmail) + viewModel.updateTimeZone(testInputNewTimeZone) + + assertEquals(viewModel.uiState.value.name, testInputNewName) + assertEquals(viewModel.uiState.value.email, testInputNewEmail) + assertEquals(viewModel.uiState.value.timeZone, testInputNewTimeZone) + assertFalse(viewModel.hasInvalidData()) + + viewModel.updateShopkeeper() + + val userDataFromLoginRepository = loginRepository.userData.first() + assertEquals(userDataFromLoginRepository.name, testInputNewName) + assertEquals(userDataFromLoginRepository.email, testInputNewEmail) + assertEquals(userDataFromLoginRepository.timeZone, testInputNewTimeZone) + assertTrue(userDataFromLoginRepository.isEmailUpdated) + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isEmailUpdated) + } + + @Test + fun isMyAccountDeleted_whenDeletingShopkeeper_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val userData = emptyUserData.copy( + name = testInputLoggedInShopkeeper.getName()!!, + email = testInputLoggedInShopkeeper.getEmail()!!, + timeZone = testInputLoggedInShopkeeper.getTimeZone()!!, + ) + + loginRepository.sendUserData(userData) + + viewModel.reload() + viewModel.deleteShopkeeper() + + val userDataFromLoginRepository = loginRepository.userData.first() + assertTrue(userDataFromLoginRepository.isMyAccountDeleted) + + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.isDeleted) + } + + @Test + fun blankName_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateName("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun blankEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("") + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun wrongFormatEmail_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateEmail("wrongFormatEmail@com") + + assertTrue(viewModel.hasInvalidDataEmail()) + } + + @Test + fun updating_withoutChanges_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val userData = emptyUserData.copy( + name = testInputLoggedInShopkeeper.getName()!!, + email = testInputLoggedInShopkeeper.getEmail()!!, + timeZone = testInputLoggedInShopkeeper.getTimeZone()!!, + ) + + loginRepository.sendUserData(userData) + + viewModel.reload() + + assertTrue(viewModel.hasInvalidData()) + } +} + +private const val LOGGED_IN_SHOPKEEPER_TYPE = "shopkeeper_sign_in" +private const val LOGGED_IN_SHOPKEEPER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME = "Account1" +private const val LOGGED_IN_SHOPKEEPER_EMAIL = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_NAME = "John Smith" +private const val LOGGED_IN_SHOPKEEPER_TIME_ZONE = "Tokyo" +private const val LOGGED_IN_SHOPKEEPER_TOKEN = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_CLIENT = "Vd6GFW-9DaZrU2pzFd-Asa" +private const val LOGGED_IN_SHOPKEEPER_UID = "john@example.com" +private const val LOGGED_IN_SHOPKEEPER_EXPIRY = "1713165114" + +private val testInputLoggedInShopkeeperData = + Data( + id = LOGGED_IN_SHOPKEEPER_ID, + type = LOGGED_IN_SHOPKEEPER_TYPE, + attributes = Attributes( + accountId = LOGGED_IN_SHOPKEEPER_ACCOUNT_ID, + personalAccountId = LOGGED_IN_SHOPKEEPER_PERSONAL_ACCOUNT_ID, + accountOwnerId = LOGGED_IN_SHOPKEEPER_ACCOUNT_OWNER_ID, + accountName = LOGGED_IN_SHOPKEEPER_ACCOUNT_NAME, + email = LOGGED_IN_SHOPKEEPER_EMAIL, + name = LOGGED_IN_SHOPKEEPER_NAME, + timeZone = LOGGED_IN_SHOPKEEPER_TIME_ZONE, + token = LOGGED_IN_SHOPKEEPER_TOKEN, + client = LOGGED_IN_SHOPKEEPER_CLIENT, + uid = LOGGED_IN_SHOPKEEPER_UID, + expiry = LOGGED_IN_SHOPKEEPER_EXPIRY, + ) + ) + +private val testInputLoggedInShopkeeper = LoggedInShopkeeper( + datum = testInputLoggedInShopkeeperData, +) + +private const val testInputNewName = "Olivia Clark" +private const val testInputNewEmail = "olivia@example.com" +private const val testInputNewTimeZone = "Hawaii" diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModelTest.kt new file mode 100644 index 0000000..35da090 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModelTest.kt @@ -0,0 +1,92 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_detail + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_detail.navigation.ShopDetailRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +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 + +/** + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ShopDetailViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val shopRepository = TestShopRepository() + + private lateinit var viewModel: ShopDetailViewModel + + @Before + fun setUp() { + viewModel = ShopDetailViewModel( + savedStateHandle = SavedStateHandle( + route = ShopDetailRoute(id = testInputShop.datum!!.id!!), + ), + shopRepository = shopRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateShop_whenSuccess_matchesShopFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val shopFromRepository = shopRepository.getShop(testInputShop.datum!!.id!!).first() + + assertEquals(shopFromRepository, uiStateValue.shop) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private val testInputShopsData = + Data( + id = SHOP_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ) + ) + +private val testInputShop = Shop( + datum = testInputShopsData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt new file mode 100644 index 0000000..60272f3 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModelTest.kt @@ -0,0 +1,135 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ShopBasicSettingsRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * To learn more about how this test handles Flows created with stateIn, see + * https://developer.android.com/kotlin/flow/test#statein + * + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ShopBasicSettingsViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val shopRepository = TestShopRepository() + + private lateinit var viewModel: ShopBasicSettingsViewModel + + @Before + fun setUp() { + viewModel = ShopBasicSettingsViewModel( + savedStateHandle = SavedStateHandle( + route = ShopBasicSettingsRoute(id = testInputShop.datum!!.id!!), + ), + shopRepository = shopRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateShop_whenSuccess_matchesShopFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val shopFromRepository = shopRepository.getShop(testInputShop.datum!!.id!!).first() + + assertEquals(shopFromRepository, uiStateValue.shop) + } + + @Test + fun stateIsUpdated_whenUpdatingShop_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateName(testInputShop.getName()) + viewModel.updateDescription(testInputShop.getDescription()) + viewModel.updateTimeZone(testInputShop.getTimeZone()) + + val uiStateValue = viewModel.uiState.value + assertEquals(uiStateValue.name, testInputShop.getName()) + assertEquals(uiStateValue.description, testInputShop.getDescription()) + assertEquals(uiStateValue.timeZone, testInputShop.getTimeZone()) + + shopRepository.sendShop(testInputShop) + viewModel.updateShop() + + assertTrue(viewModel.uiState.value.isUpdated) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun blankName_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateName("") + viewModel.updateDescription(testInputShop.getDescription()) + viewModel.updateTimeZone(testInputShop.getTimeZone()) + + assertTrue(viewModel.hasInvalidData()) + } + + @Test + fun updating_withoutChanges_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + + viewModel.reload() + + assertTrue(viewModel.hasInvalidData()) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private val testInputShopsData = + Data( + id = SHOP_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ) + ) + +private val testInputShop = Shop( + datum = testInputShopsData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModelTest.kt new file mode 100644 index 0000000..3e4baae --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModelTest.kt @@ -0,0 +1,96 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.testing.invoke +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.ui.shop_settings.navigation.ShopSettingsRoute +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * To learn more about how this test handles Flows created with stateIn, see + * https://developer.android.com/kotlin/flow/test#statein + * + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`. + * + * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency. + * * See b/340966212. + */ +@RunWith(RobolectricTestRunner::class) +class ShopSettingsViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val shopRepository = TestShopRepository() + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: ShopSettingsViewModel + + @Before + fun setUp() { + viewModel = ShopSettingsViewModel( + savedStateHandle = SavedStateHandle( + route = ShopSettingsRoute(id = testInputShop.datum!!.id!!), + ), + loginRepository = loginRepository, + shopRepository = shopRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateShop_whenSuccess_matchesShopFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShop(testInputShop) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val shopFromRepository = shopRepository.getShop(testInputShop.datum!!.id!!).first() + + assertEquals(shopFromRepository, uiStateValue.shop) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private val testInputShopsData = + Data( + id = SHOP_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ) + ) + +private val testInputShop = Shop( + datum = testInputShopsData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt new file mode 100644 index 0000000..608925d --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopCreateViewModelTest.kt @@ -0,0 +1,104 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shops + +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Shop +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +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 + +/** + * To learn more about how this test handles Flows created with stateIn, see + * https://developer.android.com/kotlin/flow/test#statein + * + * These tests use Robolectric because the subject under test (the ViewModel) uses + * `TimeZone.getDefault` which has a dependency on `android.icu.impl`. + */ +@RunWith(RobolectricTestRunner::class) +class ShopCreateViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val shopRepository = TestShopRepository() + + private lateinit var viewModel: ShopCreateViewModel + + @Before + fun setUp() { + viewModel = ShopCreateViewModel( + shopRepository = shopRepository, + ) + } + + @Test + fun stateIsInitiallyNotLoading() = runTest { + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun stateIsCreated_whenCreatingShop_becomesTrue() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val name = testInputShop.getName() + val description = testInputShop.getDescription() + val timeZone = testInputShop.getTimeZone() + + viewModel.updateName(name) + viewModel.updateDescription(description) + viewModel.updateTimeZone(timeZone) + + assertEquals(viewModel.uiState.value.name, name) + assertEquals(viewModel.uiState.value.description, description) + assertEquals(viewModel.uiState.value.timeZone, timeZone) + assertFalse(viewModel.hasInvalidData()) + + shopRepository.sendShop(testInputShop) + viewModel.createShop() + + assertTrue(viewModel.uiState.value.isCreated) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun blankName_isInvalid() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + viewModel.updateName("") + viewModel.updateDescription(testInputShop.getDescription()) + viewModel.updateTimeZone(testInputShop.getTimeZone()) + + assertTrue(viewModel.hasInvalidData()) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_NAME = "8th & Townsend" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private val testInputShopsData = + Data( + id = SHOP_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ) + ) + +private val testInputShop = Shop( + datum = testInputShopsData, +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModelTest.kt new file mode 100644 index 0000000..f569d2f --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModelTest.kt @@ -0,0 +1,101 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.shops + +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Shops +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* + +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ShopListViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + private val shopRepository = TestShopRepository() + + private lateinit var viewModel: ShopListViewModel + + @Before + fun setUp() { + viewModel = ShopListViewModel( + loginRepository = loginRepository, + shopRepository = shopRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertTrue(viewModel.uiState.value.isLoading) + } + + @Test + fun stateShops_whenSuccess_matchesShopsFromRepository() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + shopRepository.sendShops(testInputShops) + + viewModel.reload() + val uiStateValue = viewModel.uiState.value + assertTrue(uiStateValue.success) + assertFalse(uiStateValue.isLoading) + + val shopsFromRepository = shopRepository.getShops().first() + + assertEquals(shopsFromRepository, uiStateValue.shops) + } +} + +private const val SHOP_TYPE = "shop" +private const val SHOP_1_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val SHOP_2_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1B" +private const val SHOP_3_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1C" +private const val SHOP_1_NAME = "8th & Townsend" +private const val SHOP_2_NAME = "Kansas & 16th St" +private const val SHOP_3_NAME = "Safeway San Francisco 1490" +private const val SHOP_DESCRIPTION = "This is a shop." +private const val SHOP_TIME_ZONE = "Pacific Time (US & Canada)" + +private val testInputShopsData = listOf( + Data( + id = SHOP_1_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_1_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ), + ), + Data( + id = SHOP_2_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_2_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ), + ), + Data( + id = SHOP_3_ID, + type = SHOP_TYPE, + attributes = Attributes( + name = SHOP_3_NAME, + description = SHOP_DESCRIPTION, + timeZone = SHOP_TIME_ZONE, + ), + ), +) + +private val testInputShops = Shops( + datum = testInputShopsData, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8724710..089e032 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ dependencyAnalysis = "2.8.0" gmsPlugin = "4.4.2" googleOssPlugin = "0.10.6" hilt = "2.54" +junit4 = "4.13.2" kotlin = "2.1.0" kotlinxCoroutines = "1.10.1" kotlinxSerializationJson = "1.8.0" @@ -24,6 +25,7 @@ protobuf = "4.29.2" protobufPlugin = "0.9.4" retrofit = "2.11.0" retrofitKotlinxSerializationJson = "1.0.0" +robolectric = "4.14.1" sandwich = "2.1.0" sandwichRetrofitSerialization = "2.1.0" @@ -45,13 +47,17 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } +androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } androidx-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version.ref = "androidxTracing" } google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okHttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okHttp" } @@ -59,6 +65,7 @@ protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin- protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } sandwich = { module = "com.github.skydoves:sandwich", version.ref = "sandwich" } sandwich-retrofit = { module = "com.github.skydoves:sandwich-retrofit", version.ref = "sandwich" } sandwich-retrofit-serialization = { module = "com.github.skydoves:sandwich-retrofit-serialization", version.ref = "sandwichRetrofitSerialization" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..cea7a79 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/model/build.gradle.kts b/model/build.gradle.kts index ccd1722..8e72703 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -30,7 +30,7 @@ android { } } - namespace = "com.nativeapptemplate.nativeapptemplate.model" + namespace = "com.nativeapptemplate.nativeapptemplatefree.model" } dependencies { diff --git a/model/proguard-rules.pro b/model/proguard-rules.pro index bbc06f2..9b219a3 100644 --- a/model/proguard-rules.pro +++ b/model/proguard-rules.pro @@ -1 +1 @@ --keep com.nativeapptemplate.nativeapptemplate.model.** \ No newline at end of file +-keep com.nativeapptemplate.nativeapptemplatefree.model.** \ No newline at end of file