diff --git a/build.gradle.kts b/build.gradle.kts index 3a640ee..6d87c4a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,12 +46,17 @@ dependencies { runtimeOnly("com.h2database:h2") runtimeOnly("io.r2dbc:r2dbc-h2") - testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-test"){ + exclude(module = "mockito-core") + } testImplementation("io.projectreactor:reactor-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") testImplementation("org.wiremock.integrations:wiremock-spring-boot:3.2.0") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("com.ninja-squad:springmockk:4.0.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") diff --git a/src/main/kotlin/concept/stc/coroutines/DispatchersProvider.kt b/src/main/kotlin/concept/stc/coroutines/DispatchersProvider.kt new file mode 100644 index 0000000..0bb195e --- /dev/null +++ b/src/main/kotlin/concept/stc/coroutines/DispatchersProvider.kt @@ -0,0 +1,19 @@ +package concept.stc.coroutines + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import org.springframework.stereotype.Component + +/** + * Provides coroutines dispatchers. + * This class allows to replace background dispatchers with test dispatchers + * for unit and integration testing. + */ +@Component +class DispatchersProvider { + + /** + * Get the IO dispatcher. + */ + val io: CoroutineDispatcher get() = Dispatchers.IO +} diff --git a/src/main/kotlin/concept/stc/data/ApiService.kt b/src/main/kotlin/concept/stc/data/ApiService.kt index f30f2b7..e761c85 100644 --- a/src/main/kotlin/concept/stc/data/ApiService.kt +++ b/src/main/kotlin/concept/stc/data/ApiService.kt @@ -1,12 +1,10 @@ package concept.stc.data -import concept.stc.data.local.MovieRepository -import concept.stc.data.mapper.toDomain +import concept.stc.coroutines.DispatchersProvider +import concept.stc.data.local.MovieCrudRepository import concept.stc.data.mapper.toEntity import concept.stc.data.remote.ApiClient -import concept.stc.domain.model.Movie -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import org.springframework.stereotype.Service /** @@ -16,23 +14,29 @@ import org.springframework.stereotype.Service * * @param apiClient the API client. * @param repository the CRUD movie repository. + * @param dispatchers the dispatcher provider. */ @Service class ApiService( private val apiClient: ApiClient, - private val repository: MovieRepository + private val repository: MovieCrudRepository, + private val dispatchers: DispatchersProvider ) { /** * Load movies from the external API and save them to the database. * * @param title the movie title. - * - * @return the movie flow that emits the saved movies. */ - suspend fun loadMovies(title: String): Flow { - val movies = apiClient.search(title).movies - val entities = movies.map { movie -> movie.toEntity() } - return repository.saveAll(entities).map { entity -> entity.toDomain() } + suspend fun loadMovies(title: String) { + withContext(dispatchers.io) { + val movies = apiClient.search(title).movies + for (movie in movies) { + val entity = repository.getMovieByImdbId(movie.imdbID) + if (entity == null) { + repository.save(movie.toEntity()) + } + } + } } } diff --git a/src/main/kotlin/concept/stc/data/local/MovieCrudRepository.kt b/src/main/kotlin/concept/stc/data/local/MovieCrudRepository.kt new file mode 100644 index 0000000..a83d09f --- /dev/null +++ b/src/main/kotlin/concept/stc/data/local/MovieCrudRepository.kt @@ -0,0 +1,21 @@ +package concept.stc.data.local + +import concept.stc.data.local.entity.MovieEntity +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.kotlin.CoroutineCrudRepository + +/** + * Repository to manage database operations for [MovieEntity]. + */ +interface MovieCrudRepository : CoroutineCrudRepository { + + /** + * Find movie by IMDB ID. + * + * @param imdbId the IMDB ID. + * + * @return the movie entity or null if not found. + */ + @Query("SELECT * FROM movies WHERE imdb_id = :imdbId") + suspend fun getMovieByImdbId(imdbId: String): MovieEntity? +} diff --git a/src/main/kotlin/concept/stc/data/local/MovieRepository.kt b/src/main/kotlin/concept/stc/data/local/MovieRepository.kt deleted file mode 100644 index 8c912dd..0000000 --- a/src/main/kotlin/concept/stc/data/local/MovieRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package concept.stc.data.local - -import concept.stc.data.local.entity.MovieEntity -import org.springframework.data.repository.kotlin.CoroutineCrudRepository - -/** - * Repository to manage database operations for [MovieEntity]. - */ -interface MovieRepository : CoroutineCrudRepository diff --git a/src/test/kotlin/concept/stc/data/ApiServiceTest.kt b/src/test/kotlin/concept/stc/data/ApiServiceTest.kt new file mode 100644 index 0000000..7f6cdd1 --- /dev/null +++ b/src/test/kotlin/concept/stc/data/ApiServiceTest.kt @@ -0,0 +1,86 @@ +package concept.stc.data + +import concept.stc.coroutines.DispatchersProvider +import concept.stc.data.local.MovieCrudRepository +import concept.stc.data.mapper.toEntity +import concept.stc.data.remote.ApiClient +import concept.stc.data.remote.model.SearchResponse +import io.mockk.Called +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ApiServiceTest { + + private val apiClient = mockk() + private val repository = mockk() + private val dispatchers = mockk { + coEvery { io } returns UnconfinedTestDispatcher() + } + + private val service = ApiService(apiClient, repository, dispatchers) + + @AfterEach + fun tearDown() { + clearMocks(apiClient, repository) + } + + @Test + fun `when load movies, given API response, then save them to database`() = runTest { + // Given + val movie = _movie.copy(imdbID = "testId") + val searchResponse = _searchResponse.copy(movies = listOf(movie)) + + coEvery { apiClient.search(any()) } returns searchResponse + coEvery { repository.getMovieByImdbId(any()) } returns null + coEvery { repository.save(any()) } returns movie.toEntity() + + // When + service.loadMovies("test") + + // Then + coVerify { apiClient.search("test") } + coVerify { repository.getMovieByImdbId("testId") } + coVerify { repository.save(movie.toEntity()) } + } + + @Test + fun `when load movies, given movie is saved already, then should not save it`() = runTest { + // Given + val movie = _movie.copy(imdbID = "testId") + val searchResponse = _searchResponse.copy(movies = listOf(movie)) + + coEvery { apiClient.search(any()) } returns searchResponse + coEvery { repository.getMovieByImdbId(any()) } returns movie.toEntity() + coEvery { repository.save(any()) } returns movie.toEntity() + + // When + service.loadMovies("test") + + // Then + coVerify { apiClient.search("test") } + coVerify { repository.getMovieByImdbId("testId") } + coVerify { repository.save(movie.toEntity()) wasNot Called } + } + + private val _movie = SearchResponse.Movie( + title = "", + year = "", + imdbID = "", + type = "", + poster = "" + ) + + private val _searchResponse = SearchResponse( + movies = emptyList(), + totalResults = 0, + response = "" + ) +} diff --git a/src/test/kotlin/concept/stc/data/local/MovieRepositoryIntegrationTest.kt b/src/test/kotlin/concept/stc/data/local/MovieCrudRepositoryIntegrationTest.kt similarity index 73% rename from src/test/kotlin/concept/stc/data/local/MovieRepositoryIntegrationTest.kt rename to src/test/kotlin/concept/stc/data/local/MovieCrudRepositoryIntegrationTest.kt index 7496dd2..85e78a4 100644 --- a/src/test/kotlin/concept/stc/data/local/MovieRepositoryIntegrationTest.kt +++ b/src/test/kotlin/concept/stc/data/local/MovieCrudRepositoryIntegrationTest.kt @@ -15,10 +15,10 @@ import kotlin.test.assertNotNull @SpringBootTest(webEnvironment = WebEnvironment.NONE) @AutoConfigureTestDatabase(replace = Replace.NONE) -class MovieRepositoryIntegrationTest { +class MovieCrudRepositoryIntegrationTest { @Autowired - private lateinit var repository: MovieRepository + private lateinit var repository: MovieCrudRepository @AfterTest fun cleanUp() { @@ -38,6 +38,20 @@ class MovieRepositoryIntegrationTest { assertEquals("test123", saved.imdbID) } + @Test + fun `when getting movie by IMDb ID, given entity, then result is not null`() = runTest { + // Given + val entity = _entity.copy(imdbID = "test123") + repository.save(entity) + + // When + val result = repository.getMovieByImdbId("test123") + + // Then + assertNotNull(result) + assertEquals("test123", result.imdbID) + } + private val _entity = MovieEntity( title = "", year = "",