diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d3df1ea --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,21 @@ +name: Build + +on: + workflow_call: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Assemble Debug + run: ./gradlew composeApp:assembleDebug diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..0c0c799 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,43 @@ +name: Coverage + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: true + +jobs: + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Download unit test execution data + uses: actions/download-artifact@v4 + with: + name: jacoco-unit-exec + path: composeApp/build/jacoco/ + + - name: Download instrumented test coverage data + uses: actions/download-artifact@v4 + with: + name: jacoco-instrumented-ec + path: composeApp/build/outputs/code_coverage/ + + - name: Generate coverage report + run: ./gradlew composeApp:jacocoTestReport -x connectedDebugAndroidTest -x testDebugUnitTest + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: composeApp/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml + fail_ci_if_error: true diff --git a/.github/workflows/instrumented-tests.yml b/.github/workflows/instrumented-tests.yml new file mode 100644 index 0000000..6c6c09f --- /dev/null +++ b/.github/workflows/instrumented-tests.yml @@ -0,0 +1,44 @@ +name: Instrumented Tests + +on: + workflow_call: + +jobs: + instrumented-tests: + name: Instrumented Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + arch: x86_64 + script: ./gradlew composeApp:connectedDebugAndroidTest + + - name: Upload test report + uses: actions/upload-artifact@v4 + if: always() + with: + name: instrumented-test-report + path: composeApp/build/reports/androidTests/connected/ + + - name: Upload instrumented coverage data + uses: actions/upload-artifact@v4 + with: + name: jacoco-instrumented-ec + path: composeApp/build/outputs/code_coverage/ diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..bd17edf --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,34 @@ +name: PR Checks + +on: + pull_request: + branches: [ master ] + push: + branches: [ master ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + uses: ./.github/workflows/build.yml + + spotless: + uses: ./.github/workflows/spotless.yml + permissions: + contents: write + + unit-tests: + needs: build + uses: ./.github/workflows/unit-tests.yml + + instrumented-tests: + needs: build + uses: ./.github/workflows/instrumented-tests.yml + + coverage: + needs: [ unit-tests, instrumented-tests ] + uses: ./.github/workflows/coverage.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/spotless.yml b/.github/workflows/spotless.yml new file mode 100644 index 0000000..58eb3d9 --- /dev/null +++ b/.github/workflows/spotless.yml @@ -0,0 +1,32 @@ +name: Spotless + +on: + workflow_call: + +jobs: + spotless: + name: Spotless + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Apply Spotless formatting + run: ./gradlew spotlessApply + + - name: Commit formatting changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: 'style: apply spotless formatting' diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..6d4dadd --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,34 @@ +name: Unit Tests + +on: + workflow_call: + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Run unit tests + run: ./gradlew composeApp:testDebugUnitTest + + - name: Upload test report + uses: actions/upload-artifact@v4 + if: always() + with: + name: unit-test-report + path: composeApp/build/reports/tests/testDebugUnitTest/ + + - name: Upload Jacoco execution data + uses: actions/upload-artifact@v4 + with: + name: jacoco-unit-exec + path: composeApp/build/jacoco/testDebugUnitTest.exec diff --git a/README.md b/README.md index a673dd6..269e5fc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,12 @@ - + + PR Checks + + + Coverage +

RandomBoxd is a **Compose Multiplatform** project designed to fetch a random movie from a Letterboxd user's **watchlists** or **custom lists**. This app is built for **Android** and **iOS** devices. 📱🎬 diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt index 03ec453..b336cff 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt @@ -1,7 +1,6 @@ package com.randomboxd.feature.random_film.presentation import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -26,29 +25,29 @@ class RandomFilmScreenTest { @Test fun all_random_film_screen_initial_components_should_be_displayed() { composeTestRule.setContent { - RandomFilmScreenRoot { } + RandomFilmScreenRoot(onFilmClicked = {}) } composeTestRule.onNodeWithTag("test-random-film-user-name-text-field").assertIsDisplayed() composeTestRule.onNodeWithTag("test-random-film-submit-button").assertIsDisplayed() - composeTestRule.onNodeWithTag("test-loading-indicator").assertIsNotDisplayed() + composeTestRule.onNodeWithTag("test-loading-indicator").assertDoesNotExist() } @Test fun press_on_disabled_button_should_not_show_loading_indicator() { composeTestRule.setContent { - RandomFilmScreenRoot { } + RandomFilmScreenRoot(onFilmClicked = {}) } composeTestRule.onNodeWithTag("test-random-film-submit-button").performClick() composeTestRule.onNodeWithTag("test-random-film-submit-button").assertIsNotEnabled() - composeTestRule.onNodeWithTag("test-loading-indicator").assertIsNotDisplayed() + composeTestRule.onNodeWithTag("test-loading-indicator").assertDoesNotExist() } @Test fun enter_text_and_submit_button_should_show_loading_indicator() { composeTestRule.setContent { - RandomFilmScreenRoot { } + RandomFilmScreenRoot(onFilmClicked = {}) } composeTestRule.onNodeWithTag("test-random-film-user-name-text-field").performTextInput("user") @@ -59,7 +58,7 @@ class RandomFilmScreenTest { @Test fun enter_text_and_on_clear_text_field_should_remove_text_from_field() { composeTestRule.setContent { - RandomFilmScreenRoot { } + RandomFilmScreenRoot(onFilmClicked = {}) } composeTestRule.onNodeWithTag("test-random-film-user-name-text-field").performTextInput("user") @@ -86,7 +85,7 @@ class RandomFilmScreenTest { ) { } } - composeTestRule.onNodeWithTag("test-film-display").assertIsDisplayed() + composeTestRule.onNodeWithTag("test-film-display").assertExists() composeTestRule.onNodeWithTag("test-film-display").performClick() } } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmRepositoryImpl.kt deleted file mode 100644 index b18b7b2..0000000 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmRepositoryImpl.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.nacchofer31.randomboxd.random_film.data.repository_impl - -import com.nacchofer31.randomboxd.core.data.RandomBoxdEndpoints -import com.nacchofer31.randomboxd.core.domain.DataError -import com.nacchofer31.randomboxd.core.domain.ResultData -import com.nacchofer31.randomboxd.random_film.data.dto.FilmDto -import com.nacchofer31.randomboxd.random_film.data.mapper.toFilm -import com.nacchofer31.randomboxd.random_film.domain.model.Film -import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode -import com.nacchofer31.randomboxd.random_film.domain.repository.RandomFilmRepository -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import kotlinx.serialization.json.Json - -class RandomFilmRepositoryImpl( - private val httpClient: HttpClient, -) : RandomFilmRepository { - override suspend fun getRandomMovie(userName: String): ResultData { - try { - val filmResponse = - httpClient - .get( - urlString = RandomBoxdEndpoints.getUserRandomFilm(userName), - ).bodyAsText() - - val json = Json { ignoreUnknownKeys = true } - - val filmDto = json.decodeFromString(filmResponse) - - return ResultData.Success(filmDto.toFilm()) - } catch (e: Exception) { - return ResultData.Error(DataError.Remote.SERIALIZATION) - } - } - - override suspend fun getRandomMoviesFromSearchList( - searchList: Set, - filmSearchMode: FilmSearchMode, - ): ResultData { - TODO("Not yet implemented") - } -} diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt index 819fe82..3120302 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt @@ -10,10 +10,10 @@ class RandomBoxdColorsTest { fun testColors() { val colors = listOf( - Pair(RandomBoxdColors.BackgroundColor, Color(0xff2C343F)), - Pair(RandomBoxdColors.BackgroundLightColor, Color(0xff556678)), - Pair(RandomBoxdColors.BackgroundDarkColor, Color(0xff14171C)), - Pair(RandomBoxdColors.GreenAccent, Color(0xff00B021)), + Pair(RandomBoxdColors.BackgroundColor, Color(0xff1c2228)), + Pair(RandomBoxdColors.BackgroundLightColor, Color(0xff99aabb)), + Pair(RandomBoxdColors.BackgroundDarkColor, Color(0xff14181c)), + Pair(RandomBoxdColors.GreenAccent, Color(0xff00e054)), Pair(RandomBoxdColors.OrangeAccent, Color(0xfff27405)), Pair(RandomBoxdColors.BlueAccent, Color(0xff40bcf4)), Pair(RandomBoxdColors.White, Color.White), diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt index ddd4137..444e8ba 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt @@ -1,26 +1,24 @@ package com.nacchofer31.randomboxd.feature.random_film.presentation import app.cash.turbine.test -import com.nacchofer31.randomboxd.core.data.RandomBoxdHttpClientFactory -import com.nacchofer31.randomboxd.random_film.data.dto.FilmDto -import com.nacchofer31.randomboxd.random_film.data.repository_impl.RandomFilmRepositoryImpl +import com.nacchofer31.randomboxd.core.domain.DataError +import com.nacchofer31.randomboxd.core.domain.ResultData +import com.nacchofer31.randomboxd.random_film.domain.model.Film import com.nacchofer31.randomboxd.random_film.domain.repository.RandomFilmRepository import com.nacchofer31.randomboxd.random_film.domain.repository.UserNameRepository import com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction import com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmViewModel import com.nacchofer31.randomboxd.utils.dispatchers.TestDispatchers -import com.nacchofer31.randomboxd.utils.http.HttpResponseData -import io.ktor.client.HttpClient -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.http.HttpStatusCode -import io.ktor.http.headers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.kodein.mock.Mock import org.kodein.mock.generated.mock import org.kodein.mock.tests.TestsWithMocks +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -30,172 +28,141 @@ import kotlin.test.assertSame class RandomFilmViewModelTest : TestsWithMocks() { private lateinit var viewModel: RandomFilmViewModel - private lateinit var repository: RandomFilmRepository - private lateinit var httpClient: HttpClient - private lateinit var mockEngine: HttpClientEngine private lateinit var testDispatchers: TestDispatchers + @Mock lateinit var repository: RandomFilmRepository + @Mock lateinit var userNameRepository: UserNameRepository - private var defaultResponseData = - HttpResponseData( - content = """{"slug":"test-slug","image_url":"test-image_url","release_year":"2000","film_name":"test-film_name","film_length":""}""", - statusCode = HttpStatusCode.OK, + private val testFilm = + Film( + slug = "test-film", + imageUrl = "https://example.com/poster.jpg", + releaseYear = 2020, + name = "Test Film", ) override fun setUpMocks() { + repository = mocker.mock() userNameRepository = mocker.mock() mocker.every { userNameRepository.getAllUserNames() - } returns - flow { - emit(emptyList()) - } - } - - private suspend fun setUpAllMocks() { - mocker.everySuspending { - userNameRepository.addUserName(isAny()) - } returns Unit + } returns flow { emit(emptyList()) } } + @OptIn(ExperimentalCoroutinesApi::class) @BeforeTest fun setUp() { testDispatchers = TestDispatchers() + Dispatchers.setMain(testDispatchers.testDispatcher) } - private fun setUpWithResponse(responseData: HttpResponseData) { - mockEngine = - MockEngine.create { - addHandler { request -> - val relativeUrl = request.url.encodedPathAndQuery - when (relativeUrl) { - "/api?users=user" -> { - respond( - content = responseData.content, - status = responseData.statusCode, - headers = - headers { - set("Content-Type", "application/json") - }, - ) - } - - else -> { - respond("Not mocked", HttpStatusCode.NotFound) - } - } - } - } - httpClient = RandomBoxdHttpClientFactory.create(engine = mockEngine) - repository = RandomFilmRepositoryImpl(httpClient) + @OptIn(ExperimentalCoroutinesApi::class) + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() { viewModel = RandomFilmViewModel(repository, userNameRepository, testDispatchers) } @Test fun `given successful response when submit button clicked then update state with film`() = runTest(testDispatchers.testDispatcher) { - setUpAllMocks() - mocker.everySuspending { - userNameRepository.addUserName(isAny()) - } returns Unit - - setUpWithResponse(defaultResponseData) - val expectedFilm = - FilmDto( - slug = "test-slug", - name = "test-film_name", - releaseYear = "2000", - imageUrl = "test-image_url", - ) + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + mocker.everySuspending { repository.getRandomMovie(isAny()) } returns ResultData.Success(testFilm) + createViewModel() + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + viewModel.state.test { - viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) val idleState = awaitItem() assertSame(false, idleState.isLoading) - val loadingState = awaitItem() - assertSame(true, loadingState.isLoading) + // Loading state may or may not be emitted as a separate item depending on dispatcher timing + var state = awaitItem() + if (state.isLoading) state = awaitItem() - val successState = awaitItem() - assertSame(false, successState.isLoading) - assertEquals(expectedFilm.name, successState.resultFilm?.name) - assertEquals(expectedFilm.releaseYear.toInt(), successState.resultFilm?.releaseYear) + assertSame(false, state.isLoading) + assertEquals(testFilm.name, state.resultFilm?.name) + assertEquals(testFilm.releaseYear, state.resultFilm?.releaseYear) } } @Test fun `given error response when submit button clicked then update state with null film`() = runTest(testDispatchers.testDispatcher) { - setUpAllMocks() - setUpWithResponse( - HttpResponseData( - content = """{}""", - statusCode = HttpStatusCode.InternalServerError, - ), - ) + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + mocker.everySuspending { + repository.getRandomMovie(isAny()) + } returns ResultData.Error(DataError.Remote.SERIALIZATION) + createViewModel() + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + viewModel.state.test { - viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) val idleState = awaitItem() assertSame(false, idleState.isLoading) - val loadingState = awaitItem() - assertSame(true, loadingState.isLoading) + // Loading state may or may not be emitted as a separate item depending on dispatcher timing + var state = awaitItem() + if (state.isLoading) state = awaitItem() - val errorState = awaitItem() - assertSame(false, errorState.isLoading) - assertNull(errorState.resultFilm) + assertSame(false, state.isLoading) + assertNull(state.resultFilm) + assertNotNull(state.resultError) } } @Test - fun `when clear button clicked then resultFilm is null`() = + fun `when clear button clicked then result error is cleared`() = runTest(testDispatchers.testDispatcher) { - setUpAllMocks() - setUpWithResponse(defaultResponseData) + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + mocker.everySuspending { + repository.getRandomMovie(isAny()) + } returns ResultData.Error(DataError.Remote.SERIALIZATION) + createViewModel() + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + viewModel.state.test { - viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) - awaitItem() - awaitItem() - val successState = awaitItem() - assertNotNull(successState.resultFilm) + awaitItem() // idle + + // Loading state may or may not be emitted as a separate item depending on dispatcher timing + var state = awaitItem() + if (state.isLoading) state = awaitItem() + assertNotNull(state.resultError) viewModel.onAction(RandomFilmAction.OnClearButtonClick) val clearedState = awaitItem() - assertNull(clearedState.resultFilm) + assertNull(clearedState.resultError) } } @Test - fun `when onFilmClicked success`() = + fun `when submit button clicked twice then state has film both times`() = runTest(testDispatchers.testDispatcher) { - setUpAllMocks() - setUpWithResponse(defaultResponseData) + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + mocker.everySuspending { repository.getRandomMovie(isAny()) } returns ResultData.Success(testFilm) + createViewModel() + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + viewModel.state.test { - viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) - val idleState = awaitItem() - assertSame(false, idleState.isLoading) - - val loadingState = awaitItem() - assertSame(true, loadingState.isLoading) - - val successState = awaitItem() - assertNotNull(successState.resultFilm) + awaitItem() // idle + var firstState = awaitItem() + if (firstState.isLoading) firstState = awaitItem() + assertNotNull(firstState.resultFilm) viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) - - val newLoadingState = awaitItem() - assertSame(true, newLoadingState.isLoading) - - val newSuccessState = awaitItem() - assertNotNull(newSuccessState.resultFilm) + var secondState = awaitItem() + if (secondState.isLoading) secondState = awaitItem() + assertNotNull(secondState.resultFilm) } } } diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt new file mode 100644 index 0000000..d4043e5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt @@ -0,0 +1,233 @@ +package com.nacchofer31.randomboxd.random_film.data.repository_impl + +import com.nacchofer31.randomboxd.core.domain.DataError +import com.nacchofer31.randomboxd.core.domain.ResultData +import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class RandomFilmScrappingRepositoryTest { + private val paginationHtml = + """
  • 1
""" + + private val filmListHtml = + """ +
    +
  • +
    +
  • +
+ """.trimIndent() + + private val alternateFilmListHtml = + """ +
    +
  • +
    +
  • +
+ """.trimIndent() + + private val filmDetailHtml = + """ + + + + """.trimIndent() + + private val emptyFilmListHtml = "
    " + + private fun createRepository(mockEngine: MockEngine) = RandomFilmScrappingRepository(HttpClient(mockEngine)) + + @Test + fun `getRandomMovie returns film on successful watchlist scraping`() = + runTest { + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = + when { + path.contains("/page/") -> filmListHtml + path.startsWith("/film/") -> filmDetailHtml + else -> paginationHtml + }, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + + val result = repository.getRandomMovie("user") + + assertIs>(result) + val film = (result as ResultData.Success).data + assertEquals("Test Film", film.name) + assertEquals(2020, film.releaseYear) + assertEquals("https://example.com/poster.jpg", film.imageUrl) + } + + @Test + fun `getRandomMovie returns NO_RESULTS error when watchlist is empty`() = + runTest { + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = if (path.contains("/page/")) emptyFilmListHtml else paginationHtml, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + + val result = repository.getRandomMovie("user") + + assertIs>(result) + assertEquals(DataError.Remote.NO_RESULTS, (result as ResultData.Error).error) + } + + @Test + fun `getRandomMovie with list slash notation returns film`() = + runTest { + val listFilmHtml = + """ +
      +
    • +
      +
    • +
    + """.trimIndent() + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = + when { + path.contains("/page/") -> listFilmHtml + path.startsWith("/film/") -> filmDetailHtml + else -> paginationHtml + }, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + + val result = repository.getRandomMovie("user/my-list") + + assertIs>(result) + assertNotNull((result as ResultData.Success).data) + } + + @Test + fun `getRandomMoviesFromSearchList UNION returns film from any user`() = + runTest { + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = + when { + path.contains("/page/") -> filmListHtml + path.startsWith("/film/") -> filmDetailHtml + else -> paginationHtml + }, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + + val result = + repository.getRandomMoviesFromSearchList( + searchList = setOf("user1", "user2"), + filmSearchMode = FilmSearchMode.UNION, + ) + + assertIs>(result) + assertNotNull((result as ResultData.Success).data) + } + + @Test + fun `getRandomMoviesFromSearchList INTERSECTION with common films returns film`() = + runTest { + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = + when { + path.contains("/page/") -> filmListHtml + path.startsWith("/film/") -> filmDetailHtml + else -> paginationHtml + }, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + + val result = + repository.getRandomMoviesFromSearchList( + searchList = setOf("user1", "user2"), + filmSearchMode = FilmSearchMode.INTERSECTION, + ) + + assertIs>(result) + assertNotNull((result as ResultData.Success).data) + } + + @Test + fun `getRandomMoviesFromSearchList INTERSECTION with no common films returns NO_RESULTS error`() = + runTest { + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = + when { + path.contains("/page/") && path.contains("user1") -> filmListHtml + path.contains("/page/") && path.contains("user2") -> alternateFilmListHtml + else -> paginationHtml + }, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + + val result = + repository.getRandomMoviesFromSearchList( + searchList = setOf("user1", "user2"), + filmSearchMode = FilmSearchMode.INTERSECTION, + ) + + assertIs>(result) + assertEquals(DataError.Remote.NO_RESULTS, (result as ResultData.Error).error) + } + + @Test + fun `getRandomMoviesFromSearchList with empty search list returns error`() = + runTest { + val mockEngine = MockEngine { _ -> respond("", HttpStatusCode.OK) } + val repository = createRepository(mockEngine) + + val result = + repository.getRandomMoviesFromSearchList( + searchList = emptySet(), + filmSearchMode = FilmSearchMode.UNION, + ) + + assertIs>(result) + assertEquals(DataError.Remote.SERIALIZATION, (result as ResultData.Error).error) + } +} diff --git a/gradle.properties b/gradle.properties index 397c95d..ca8f7bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,13 +16,10 @@ kotlin.native.disableCompilerDaemon=true #Room ksp.useKSP2=true -android.defaults.buildfeatures.resvalues=true -android.sdk.defaultTargetSdkToCompileSdkIfUnset=false -android.enableAppCompileTimeRClass=false -android.usesSdkInManifest.disallowed=false android.uniquePackageNames=false android.dependency.useConstraints=true +android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false android.r8.strictFullModeForKeepRules=false -android.r8.optimizedResourceShrinking=false +# Required for KMP + com.android.application compatibility until AGP 10.0 migration android.builtInKotlin=false android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c03e15e..b97270e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ turbine = "1.2.1" coroutines-test = "1.10.2" uiTestAndroid = "1.9.0" uiTestJunit4Android = "1.9.0" -androidx-test-runner = "1.7.0" +androidx-test-runner = "1.5.0" mockmp = "2.0.2" [libraries]