diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index bb44937..c22b6fa 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/java/com/santimattius/template/di/AppModule.kt b/app/src/main/java/com/santimattius/template/di/AppModule.kt
index e48fa76..9280156 100644
--- a/app/src/main/java/com/santimattius/template/di/AppModule.kt
+++ b/app/src/main/java/com/santimattius/template/di/AppModule.kt
@@ -5,28 +5,33 @@ import com.santimattius.core.data.client.database.TheMovieDataBase
import com.santimattius.core.data.client.network.RetrofitServiceCreator
import com.santimattius.core.data.client.network.TheMovieDBService
import com.santimattius.template.BuildConfig
-import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Singleton
@Module
class AppModule {
- @Factory
+ @Singleton
fun provideAppDatabase(context: Context): TheMovieDataBase =
TheMovieDataBase.get(context)
- @Factory
+ @Singleton
fun provideMovieDBService(serviceCreator: RetrofitServiceCreator): TheMovieDBService =
serviceCreator.createService(TheMovieDBService::class.java)
@Singleton
- fun provideRetrofit(): RetrofitServiceCreator {
+ fun provideRetrofit(@Named("base_url") baseUrl: String): RetrofitServiceCreator {
return RetrofitServiceCreator(
- baseUrl = "https://api.themoviedb.org",
+ baseUrl = baseUrl,
apiKey = BuildConfig.apiKey
)
}
+
+ @Named("base_url")
+ @Singleton
+ @Suppress("FunctionOnlyReturningConstant")
+ fun provideBaseUrl() = "https://api.themoviedb.org"
}
\ No newline at end of file
diff --git a/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelDiTest.kt b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelDiTest.kt
new file mode 100644
index 0000000..cfcaddd
--- /dev/null
+++ b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelDiTest.kt
@@ -0,0 +1,130 @@
+package com.santimattius.template.ui.compose
+
+import android.app.Application
+import android.os.Build
+import androidx.lifecycle.viewmodel.testing.viewModelScenario
+import androidx.test.core.app.ApplicationProvider
+import app.cash.turbine.test
+import com.santimattius.core.data.client.database.TheMovieDataBase
+import com.santimattius.template.ui.compose.models.Messages
+import com.santimattius.template.ui.models.MovieUiModel
+import com.santimattius.template.ui.rules.RoomTestRule
+import com.santimattius.template.ui.rules.loadModule
+import com.santimattius.test.data.MovieMother
+import com.santimattius.test.data.TheMovieDBServiceMother
+import com.santimattius.test.rules.MainCoroutinesTestRule
+import com.santimattius.test.rules.MockWebServerRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+import org.koin.ksp.generated.com_santimattius_template_di_AppModule
+import org.koin.ksp.generated.com_santimattius_template_di_DataModule
+import org.koin.ksp.generated.defaultModule
+import org.koin.test.KoinTest
+import org.koin.test.KoinTestRule
+import org.koin.test.get
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(
+ manifest = Config.NONE,
+ sdk = [Build.VERSION_CODES.R],
+ instrumentedPackages = ["androidx.loader.content"],
+ application = Application::class
+)
+class HomeComposeViewModelDiTest : KoinTest {
+
+ private val appContext = ApplicationProvider.getApplicationContext()
+
+ @get:Rule(order = 0)
+ val mockWebServerRule = MockWebServerRule {
+ TheMovieDBServiceMother.createPopularMovieResponse()
+ }
+
+ @get:Rule(order = 1)
+ val roomTestRule = RoomTestRule(appContext, TheMovieDataBase::class.java)
+
+
+ @get:Rule(order = 2)
+ var koinTestRule = KoinTestRule.create {
+ modules(
+ com_santimattius_template_di_DataModule,
+ com_santimattius_template_di_AppModule,
+ defaultModule,
+ )
+ }
+
+
+ @get:Rule(order = 3)
+ val mainCoroutinesTestRule = MainCoroutinesTestRule()
+
+ @Before
+ fun setUp() {
+ koinTestRule.loadModule(
+ module {
+ //override base url and database
+ factory(named("base_url")) { mockWebServerRule.baseUrl }
+ factory { roomTestRule.db }
+ }
+ )
+ }
+
+ @Test
+ fun `onFavorite should update state with success message when adding to favorite succeeds`() {
+ viewModelScenario { HomeComposeViewModel(movieRepository = get()) }.use {
+ val dto = MovieMother.createMovie()
+ val movie = MovieUiModel(
+ id = dto.id,
+ title = dto.title,
+ imageUrl = dto.poster,
+ favorite = false
+ )
+ val viewModel = it.viewModel
+
+ runTest(mainCoroutinesTestRule.testDispatcher) {
+ viewModel.onFavorite(movie)
+ viewModel.state.test {
+ awaitItem()
+ assertEquals(
+ Messages.Success("Add ${movie.title} to favorite"),
+ awaitItem().successMessages
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `onFavorite should update state with success message when removing from favorite succeeds`() {
+ viewModelScenario { HomeComposeViewModel(movieRepository = get()) }.use {
+ val dto = MovieMother.createMovie()
+ val movie = MovieUiModel(
+ id = dto.id,
+ title = dto.title,
+ imageUrl = dto.poster,
+ favorite = true
+ )
+ val viewModel = it.viewModel
+ runTest(mainCoroutinesTestRule.testDispatcher) {
+ viewModel.onFavorite(movie)
+ viewModel.state.test {
+ awaitItem()
+ assertEquals(
+ Messages.Success("Remove ${movie.title} from favorite"),
+ awaitItem().successMessages
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelTest.kt b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelTest.kt
new file mode 100644
index 0000000..617f610
--- /dev/null
+++ b/app/src/test/java/com/santimattius/template/ui/compose/HomeComposeViewModelTest.kt
@@ -0,0 +1,127 @@
+package com.santimattius.template.ui.compose
+
+import android.app.Application
+import android.os.Build
+import androidx.lifecycle.viewmodel.testing.viewModelScenario
+import androidx.test.core.app.ApplicationProvider
+import app.cash.turbine.test
+import com.santimattius.core.data.client.database.TheMovieDataBase
+import com.santimattius.core.data.client.network.RetrofitServiceCreator
+import com.santimattius.core.data.client.network.TheMovieDBService
+import com.santimattius.core.data.datasources.implementation.RetrofitMovieNetworkDataSource
+import com.santimattius.core.data.datasources.implementation.RoomMovieLocalDataSource
+import com.santimattius.core.data.repositories.TMDbRepository
+import com.santimattius.template.ui.compose.models.Messages
+import com.santimattius.template.ui.models.MovieUiModel
+import com.santimattius.template.ui.rules.RoomTestRule
+import com.santimattius.template.ui.rules.loadModule
+import com.santimattius.test.data.MovieMother
+import com.santimattius.test.data.TheMovieDBServiceMother
+import com.santimattius.test.rules.MainCoroutinesTestRule
+import com.santimattius.test.rules.MockWebServerRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+import org.koin.ksp.generated.com_santimattius_template_di_AppModule
+import org.koin.ksp.generated.com_santimattius_template_di_DataModule
+import org.koin.ksp.generated.defaultModule
+import org.koin.test.KoinTest
+import org.koin.test.KoinTestRule
+import org.koin.test.get
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(
+ manifest = Config.NONE,
+ sdk = [Build.VERSION_CODES.R],
+ instrumentedPackages = ["androidx.loader.content"],
+ application = Application::class
+)
+class HomeComposeViewModelTest {
+
+ private lateinit var movieRepository: TMDbRepository
+ private val appContext = ApplicationProvider.getApplicationContext()
+
+ @get:Rule(order = 0)
+ val mockWebServerRule = MockWebServerRule {
+ TheMovieDBServiceMother.createPopularMovieResponse()
+ }
+
+ @get:Rule(order = 1)
+ val roomTestRule = RoomTestRule(appContext, TheMovieDataBase::class.java)
+
+
+ @get:Rule(order = 2)
+ val mainCoroutinesTestRule = MainCoroutinesTestRule()
+
+ @Before
+ fun setUp() {
+ val movieLocalDataSource = RoomMovieLocalDataSource(roomTestRule.db)
+ val serviceCreator = RetrofitServiceCreator(mockWebServerRule.baseUrl, "test")
+ val movieNetworkDataSource = RetrofitMovieNetworkDataSource(
+ serviceCreator.createService(
+ TheMovieDBService::class.java
+ )
+ )
+ movieRepository = TMDbRepository(movieNetworkDataSource, movieLocalDataSource)
+ }
+
+ @Test
+ fun `onFavorite should update state with success message when adding to favorite succeeds`() {
+ viewModelScenario { HomeComposeViewModel(movieRepository = movieRepository) }.use {
+ val dto = MovieMother.createMovie()
+ val movie = MovieUiModel(
+ id = dto.id,
+ title = dto.title,
+ imageUrl = dto.poster,
+ favorite = false
+ )
+ val viewModel = it.viewModel
+
+ runTest(mainCoroutinesTestRule.testDispatcher) {
+ viewModel.onFavorite(movie)
+ viewModel.state.test {
+ awaitItem()
+ assertEquals(
+ Messages.Success("Add ${movie.title} to favorite"),
+ awaitItem().successMessages
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `onFavorite should update state with success message when removing from favorite succeeds`() {
+ viewModelScenario { HomeComposeViewModel(movieRepository = movieRepository) }.use {
+ val dto = MovieMother.createMovie()
+ val movie = MovieUiModel(
+ id = dto.id,
+ title = dto.title,
+ imageUrl = dto.poster,
+ favorite = true
+ )
+ val viewModel = it.viewModel
+ runTest(mainCoroutinesTestRule.testDispatcher) {
+ viewModel.onFavorite(movie)
+ viewModel.state.test {
+ awaitItem()
+ assertEquals(
+ Messages.Success("Remove ${movie.title} from favorite"),
+ awaitItem().successMessages
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/santimattius/template/ui/rules/KoinTestRule.kt b/app/src/test/java/com/santimattius/template/ui/rules/KoinTestRule.kt
new file mode 100644
index 0000000..aa62f76
--- /dev/null
+++ b/app/src/test/java/com/santimattius/template/ui/rules/KoinTestRule.kt
@@ -0,0 +1,50 @@
+package com.santimattius.template.ui.rules
+
+import org.koin.core.module.Module
+import org.koin.test.KoinTestRule
+
+/**
+ * Loads a single Koin [Module] into the Koin container within the scope of this [KoinTestRule].
+ *
+ * This function simplifies the process of loading a module specifically for testing purposes.
+ * It delegates the actual loading process to Koin's `loadModules` function.
+ *
+ * @param module The Koin [Module] to be loaded.
+ * @param allowOverride If `true`, allows definitions in the loaded module to override
+ * existing definitions. Defaults to `true`.
+ * @param createEagerInstances If `true`, forces the creation of all eager instances
+ * defined in the loaded module. Defaults to `false`.
+ *
+ * @see org.koin.core.Koin.loadModules
+ * @see org.koin.core.module.Module
+ * @see org.koin.test.KoinTestRule
+ *
+ * Example Usage:
+ * ```
+ * val myModule = module {
+ * single { MyDependency() }
+ * }
+ *
+ * @get:Rule
+ * val koinTestRule = KoinTestRule.create {
+ * // ... other configurations
+ * }
+ *
+ * @Test
+ * fun testMyComponent() {
+ * koinTestRule.loadModule(myModule)
+ * // ... now the module is loaded and ready for usage
+ * }
+ * ```
+ */
+fun KoinTestRule.loadModule(
+ module: Module,
+ allowOverride: Boolean = true,
+ createEagerInstances: Boolean = false
+) {
+ koin.loadModules(
+ modules = listOf(module),
+ allowOverride = allowOverride,
+ createEagerInstances = createEagerInstances
+ )
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/santimattius/template/ui/rules/RoomTestRule.kt b/app/src/test/java/com/santimattius/template/ui/rules/RoomTestRule.kt
new file mode 100644
index 0000000..7b8ca10
--- /dev/null
+++ b/app/src/test/java/com/santimattius/template/ui/rules/RoomTestRule.kt
@@ -0,0 +1,34 @@
+package com.santimattius.template.ui.rules
+
+import android.content.Context
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+/**
+ * A JUnit TestRule for testing Room database interactions.
+ *
+ * This rule provides an in-memory instance of a Room database for testing purposes.
+ * It handles the setup and teardown of the database, ensuring a clean state for each test.
+ *
+ * @param appContext The application context used to build the in-memory database.
+ * @param klass The class of the Room database to be tested (e.g., `MyDatabase::class.java`).
+ * @param T The type of the Room database, which must extend [RoomDatabase].
+ */
+class RoomTestRule(
+ private val appContext: Context,
+ private val klass: Class
+) : TestWatcher() {
+
+ lateinit var db: T
+
+ override fun starting(description: Description?) {
+ db = Room.inMemoryDatabaseBuilder(appContext, klass)
+ .allowMainThreadQueries().build()
+ }
+
+ override fun finished(description: Description?) {
+ db.close()
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt b/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt
index cf0b41b..7c1b71f 100644
--- a/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt
+++ b/core/src/main/java/com/santimattius/core/data/client/database/MovieDao.kt
@@ -7,7 +7,6 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
-import androidx.room.Upsert
import com.santimattius.core.data.models.MovieEntity
import kotlinx.coroutines.flow.Flow
@@ -36,10 +35,10 @@ interface MovieDao {
suspend fun update(movies: MovieEntity)
@Query("UPDATE movie SET favorite = 1 WHERE id = :movieId")
- suspend fun addToFavorite(movieId: Long)
+ suspend fun addToFavorite(movieId: Long): Int
@Query("UPDATE movie SET favorite = 0 WHERE id = :movieId")
- suspend fun removeFromFavorite(movieId: Long)
+ suspend fun removeFromFavorite(movieId: Long): Int
@Query(
"""
diff --git a/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt b/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt
index 51e72dc..d9eef1e 100644
--- a/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt
+++ b/shared-test/src/main/java/com/santimattius/test/rules/MockWebServerRule.kt
@@ -7,7 +7,8 @@ import org.junit.runner.Description
class MockWebServerRule(
private val server: MockWebServer = MockWebServer(),
- private val port: Int = 0
+ private val port: Int = 0,
+ private val initialResponse: (() -> MockResponse)? = null
) : TestWatcher() {
val baseUrl: String
@@ -17,6 +18,7 @@ class MockWebServerRule(
override fun starting(description: Description) {
super.starting(description)
server.start(port)
+ initialResponse?.let { server.enqueue(it.invoke()) }
}
override fun finished(description: Description) {