From 52a99a82fc77dfe462c549bc28abd089913c6ddd Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Tue, 27 Sep 2022 17:16:05 +0300 Subject: [PATCH 1/4] Issue#41 Copy full example into separate module with Hilt Integration --- ...MigrationToLatestInstrumentedSharedTest.kt | 3 +- .../MainActivityInstrumentedSharedTest.kt | 3 - .../AuthActivityInstrumentedSharedTest.kt | 3 - app/build.gradle | 4 - build.gradle | 2 + hilt/hilt-app-shared-test/.gitignore | 1 + hilt/hilt-app-shared-test/build.gradle | 49 ++++ hilt/hilt-app-shared-test/consumer-rules.pro | 0 hilt/hilt-app-shared-test/proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 5 + .../hilt/test/shared/di/TestBaseUrlHolder.kt | 8 + ...MigrationToLatestInstrumentedSharedTest.kt | 79 ++++++ .../MockServerScenarioSetupTestRule.kt | 37 +++ .../idling/AsyncDiffUtilInstantTestRule.kt | 32 +++ .../idling/DatabaseDispatcherTestRule.kt | 52 ++++ .../idling/MainDispatcherTestRule.kt | 14 + .../idling/NetworkSynchronizationHelper.kt | 23 ++ .../statesetup/SetupAuthenticationState.kt | 69 +++++ .../storage/TestDatabaseInitialization.kt | 48 ++++ .../ui/NetworkSynchronizedActivityTest.kt | 41 +++ .../AuthActivityInstrumentedSharedTest.kt | 138 ++++++++++ .../hilt/test/shared/ui/auth/LoginRobot.kt | 94 +++++++ .../hilt/test/shared/ui/home/HomeRobot.kt | 123 +++++++++ .../MainActivityInstrumentedSharedTest.kt | 214 +++++++++++++++ .../SplashActivityInstrumentedSharedTest.kt | 101 +++++++ .../hilt/test/shared/ui/splash/SplashRobot.kt | 36 +++ hilt/hilt-app/.gitignore | 1 + hilt/hilt-app/build.gradle | 127 +++++++++ hilt/hilt-app/consumer-rules.pro | 0 hilt/hilt-app/proguard-rules.pro | 21 ++ .../1.json | 34 +++ .../2.json | 34 +++ .../hilt/di/HttpsConfigurationModule.kt | 25 ++ .../showcase/hilt/di/TestBaseUrlModule.kt | 18 ++ .../di/TestDatabaseInitializationModule.kt | 24 ++ .../hilt/di/TestUserDataLocalStorageModule.kt | 25 ++ .../showcase/hilt/runner/HiltTestRunner.kt | 12 + .../MigrationToLatestInstrumentedTest.kt | 8 + .../ui/auth/AuthActivityInstrumentedTest.kt | 10 + .../ui/compose/AuthComposeInstrumentedTest.kt | 199 ++++++++++++++ .../hilt/ui/compose/ComposeLoginRobot.kt | 52 ++++ .../hilt/ui/compose/ComposeNavigationRobot.kt | 18 ++ .../ui/home/MainActivityInstrumentedTest.kt | 10 + .../splash/SplashActivityInstrumentedTest.kt | 10 + hilt/hilt-app/src/main/AndroidManifest.xml | 44 +++ .../showcase/hilt/TestShowcaseApplication.kt | 7 + .../showcase/hilt/compose/ComposeActivity.kt | 30 +++ .../hilt/compose/screen/AppNavigation.kt | 81 ++++++ .../compose/screen/AppNavigationEntryPoint.kt | 25 ++ .../compose/screen/auth/AuthEntryPoint.kt | 25 ++ .../hilt/compose/screen/auth/AuthScreen.kt | 211 +++++++++++++++ .../compose/screen/auth/AuthScreenState.kt | 109 ++++++++ .../compose/screen/home/HomeEntryPoint.kt | 33 +++ .../hilt/compose/screen/home/HomeScreen.kt | 125 +++++++++ .../compose/screen/home/HomeScreenState.kt | 133 +++++++++ .../compose/screen/splash/SplashScreen.kt | 37 +++ .../fnives/test/showcase/hilt/di/AppModule.kt | 42 +++ .../test/showcase/hilt/di/BaseUrlModule.kt | 16 ++ .../test/showcase/hilt/di/StorageModule.kt | 20 ++ .../hilt/di/UserDataLocalStorageModule.kt | 20 ++ .../session/SessionExpirationListenerImpl.kt | 25 ++ .../showcase/hilt/storage/LocalDatabase.kt | 16 ++ .../storage/SharedPreferencesManagerImpl.kt | 68 +++++ .../database/DatabaseInitialization.kt | 15 ++ .../FavouriteContentLocalStorageImpl.kt | 23 ++ .../hilt/storage/favourite/FavouriteDao.kt | 21 ++ .../hilt/storage/favourite/FavouriteEntity.kt | 11 + .../hilt/storage/migation/Migration1To2.kt | 14 + .../showcase/hilt/ui/IntentCoordinator.kt | 15 ++ .../showcase/hilt/ui/ViewModelDelegate.kt | 12 + .../showcase/hilt/ui/auth/AuthActivity.kt | 57 ++++ .../showcase/hilt/ui/auth/AuthViewModel.kt | 69 +++++ .../hilt/ui/auth/SetTextIfNotSameObserver.kt | 13 + .../hilt/ui/home/FavouriteContentAdapter.kt | 55 ++++ .../showcase/hilt/ui/home/MainActivity.kt | 72 +++++ .../showcase/hilt/ui/home/MainViewModel.kt | 84 ++++++ .../test/showcase/hilt/ui/shared/Event.kt | 11 + .../ui/shared/VerticalSpaceItemDecoration.kt | 13 + .../hilt/ui/shared/ViewBindingAdapter.kt | 6 + .../showcase/hilt/ui/shared/ViewExtension.kt | 24 ++ .../ui/shared/executor/AsyncTaskExecutor.kt | 28 ++ .../ui/shared/executor/DefaultTaskExecutor.kt | 34 +++ .../hilt/ui/shared/executor/TaskExecutor.kt | 10 + .../showcase/hilt/ui/splash/SplashActivity.kt | 30 +++ .../hilt/ui/splash/SplashViewModel.kt | 31 +++ .../ic_compose_launcher_foreground.xml | 46 ++++ .../drawable-v24/ic_launcher_foreground.xml | 31 +++ .../src/main/res/drawable/favorite_24.xml | 10 + .../main/res/drawable/favorite_border_24.xml | 10 + .../res/drawable/ic_launcher_background.xml | 10 + .../src/main/res/drawable/logout_24.xml | 11 + .../src/main/res/drawable/show_password.xml | 92 +++++++ .../res/layout/activity_authentication.xml | 98 +++++++ .../src/main/res/layout/activity_main.xml | 54 ++++ .../src/main/res/layout/activity_splash.xml | 16 ++ .../res/layout/item_favourite_content.xml | 56 ++++ hilt/hilt-app/src/main/res/menu/main.xml | 9 + .../mipmap-anydpi-v26/ic_compose_launcher.xml | 5 + .../ic_compose_launcher_round.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/mipmap-hdpi/ic_compose_launcher.png | Bin 0 -> 3232 bytes .../mipmap-hdpi/ic_compose_launcher_round.png | Bin 0 -> 4998 bytes .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1739 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3463 bytes .../res/mipmap-mdpi/ic_compose_launcher.png | Bin 0 -> 2349 bytes .../mipmap-mdpi/ic_compose_launcher_round.png | Bin 0 -> 3179 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1331 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2208 bytes .../res/mipmap-xhdpi/ic_compose_launcher.png | Bin 0 -> 4489 bytes .../ic_compose_launcher_round.png | Bin 0 -> 7126 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2491 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4988 bytes .../res/mipmap-xxhdpi/ic_compose_launcher.png | Bin 0 -> 6567 bytes .../ic_compose_launcher_round.png | Bin 0 -> 10979 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3620 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 7728 bytes .../mipmap-xxxhdpi/ic_compose_launcher.png | Bin 0 -> 9124 bytes .../ic_compose_launcher_round.png | Bin 0 -> 15485 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 5121 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 11062 bytes .../src/main/res/values-night/themes.xml | 16 ++ hilt/hilt-app/src/main/res/values/colors.xml | 10 + hilt/hilt-app/src/main/res/values/dimens.xml | 11 + hilt/hilt-app/src/main/res/values/strings.xml | 14 + hilt/hilt-app/src/main/res/values/themes.xml | 16 ++ .../hilt/di/HttpsConfigurationModule.kt | 25 ++ .../showcase/hilt/di/TestBaseUrlModule.kt | 18 ++ .../di/TestDatabaseInitializationModule.kt | 24 ++ .../hilt/storage/UserDataLocalStorageTest.kt | 73 +++++ ...ContentLocalStorageImplInstrumentedTest.kt | 147 ++++++++++ .../MigrationToLatestInstrumentedTest.kt | 8 + ...RobolectricAuthActivityInstrumentedTest.kt | 173 ++++++++++++ .../showcase/hilt/ui/RobolectricLoginRobot.kt | 71 +++++ .../ui/auth/AuthActivityInstrumentedTest.kt | 10 + .../ui/home/MainActivityInstrumentedTest.kt | 10 + .../splash/SplashActivityInstrumentedTest.kt | 10 + .../hilt/ui/auth/AuthViewModelTest.kt | 216 +++++++++++++++ .../hilt/ui/home/MainViewModelTest.kt | 253 ++++++++++++++++++ .../test/showcase/hilt/ui/shared/EventTest.kt | 53 ++++ .../hilt/ui/splash/SplashViewModelTest.kt | 63 +++++ .../org.mockito.plugins.MockMaker | 1 + .../src/test/resources/robolectric.properties | 3 + hilt/hilt-core/.gitignore | 1 + hilt/hilt-core/build.gradle | 35 +++ hilt/hilt-core/consumer-rules.pro | 0 hilt/hilt-core/proguard-rules.pro | 21 ++ .../content/AddContentToFavouriteUseCase.kt | 13 + .../hilt/core/content/ContentRepository.kt | 41 +++ .../hilt/core/content/FetchContentUseCase.kt | 8 + .../hilt/core/content/GetAllContentUseCase.kt | 47 ++++ .../RemoveContentFromFavouritesUseCase.kt | 14 + .../test/showcase/hilt/core/di/CoreModule.kt | 33 +++ .../hilt/core/di/LoggedInModuleInject.kt | 8 + .../hilt/core/login/IsUserLoggedInUseCase.kt | 11 + .../showcase/hilt/core/login/LoginUseCase.kt | 31 +++ .../showcase/hilt/core/login/LogoutUseCase.kt | 15 ++ .../core/session/SessionExpirationAdapter.kt | 11 + .../core/session/SessionExpirationListener.kt | 5 + .../showcase/hilt/core/shared/AnswerUtils.kt | 26 ++ .../showcase/hilt/core/shared/Optional.kt | 3 + .../hilt/core/shared/UnexpectedException.kt | 11 + .../NetworkSessionLocalStorageAdapter.kt | 16 ++ .../hilt/core/storage/UserDataLocalStorage.kt | 7 + .../content/FavouriteContentLocalStorage.kt | 13 + .../AddContentToFavouriteUseCaseTest.kt | 59 ++++ .../core/content/ContentRepositoryTest.kt | 152 +++++++++++ .../core/content/FetchContentUseCaseTest.kt | 55 ++++ .../core/content/GetAllContentUseCaseTest.kt | 222 +++++++++++++++ .../RemoveContentFromFavouritesUseCaseTest.kt | 57 ++++ .../content/TurbineContentRepositoryTest.kt | 167 ++++++++++++ .../TurbineGetAllContentUseCaseTest.kt | 230 ++++++++++++++++ .../hilt/core/di/TestCoreComponent.kt | 35 +++ .../core/login/IsUserLoggedInUseCaseTest.kt | 66 +++++ .../hilt/core/login/LoginUseCaseTest.kt | 105 ++++++++ .../hilt/core/login/LogoutUseCaseTest.kt | 71 +++++ .../session/SessionExpirationAdapterTest.kt | 38 +++ .../hilt/core/shared/AnswerUtilsKtTest.kt | 90 +++++++ .../NetworkSessionLocalStorageAdapterTest.kt | 59 ++++ .../core/testutil/AwaitElementEmitCount.kt | 24 ++ .../org.mockito.plugins.MockMaker | 1 + .../fake/FakeFavouriteContentLocalStorage.kt | 30 +++ .../fake/FakeUserDataLocalStorage.kt | 6 + hilt/hilt-network-di-test-util/.gitignore | 1 + hilt/hilt-network-di-test-util/build.gradle | 43 +++ .../consumer-rules.pro | 0 .../proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 5 + .../HttpsConfigurationModuleTemplate.kt | 38 +++ .../testutil/NetworkSynchronization.kt | 37 +++ .../network/testutil/OkHttp3IdlingResource.kt | 76 ++++++ hilt/hilt-network/.gitignore | 1 + hilt/hilt-network/build.gradle | 36 +++ hilt/hilt-network/consumer-rules.pro | 0 hilt/hilt-network/proguard-rules.pro | 21 ++ .../hilt/network/auth/LoginErrorConverter.kt | 36 +++ .../hilt/network/auth/LoginRemoteSource.kt | 12 + .../network/auth/LoginRemoteSourceImpl.kt | 28 ++ .../hilt/network/auth/LoginService.kt | 18 ++ .../network/auth/model/CredentialsRequest.kt | 12 + .../hilt/network/auth/model/LoginResponse.kt | 12 + .../auth/model/LoginStatusResponses.kt | 8 + .../network/content/ContentRemoteSource.kt | 11 + .../content/ContentRemoteSourceImpl.kt | 29 ++ .../hilt/network/content/ContentResponse.kt | 16 ++ .../hilt/network/content/ContentService.kt | 9 + .../hilt/network/di/BindsBaseOkHttpClient.kt | 16 ++ .../hilt/network/di/HiltNetworkModule.kt | 87 ++++++ .../hilt/network/di/OkhttpClientExtension.kt | 12 + .../hilt/network/di/SessionLessQualifier.kt | 6 + .../hilt/network/di/SessionQualifier.kt | 6 + .../AuthenticationHeaderInterceptor.kt | 13 + .../session/AuthenticationHeaderUtils.kt | 20 ++ .../NetworkSessionExpirationListener.kt | 6 + .../session/NetworkSessionLocalStorage.kt | 8 + .../network/session/SessionAuthenticator.kt | 39 +++ .../hilt/network/shared/ExceptionWrapper.kt | 28 ++ .../network/shared/PlatformInterceptor.kt | 13 + .../shared/exceptions/NetworkException.kt | 3 + .../shared/exceptions/ParsingException.kt | 3 + .../network/auth/LoginErrorConverterTest.kt | 78 ++++++ .../LoginRemoteSourceRefreshActionImplTest.kt | 99 +++++++ .../network/auth/LoginRemoteSourceTest.kt | 146 ++++++++++ .../content/ContentRemoteSourceImplTest.kt | 123 +++++++++ .../network/content/SessionExpirationTest.kt | 109 ++++++++ .../network/testutil/TestNetworkComponent.kt | 44 +++ .../resources/success_response_login.json | 4 + .../MockServerScenarioSetupExtensions.kt | 21 ++ settings.gradle | 5 + 229 files changed, 8416 insertions(+), 11 deletions(-) create mode 100644 hilt/hilt-app-shared-test/.gitignore create mode 100644 hilt/hilt-app-shared-test/build.gradle create mode 100644 hilt/hilt-app-shared-test/consumer-rules.pro create mode 100644 hilt/hilt-app-shared-test/proguard-rules.pro create mode 100644 hilt/hilt-app-shared-test/src/main/AndroidManifest.xml create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/di/TestBaseUrlHolder.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/MockServerScenarioSetupTestRule.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/AsyncDiffUtilInstantTestRule.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/DatabaseDispatcherTestRule.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/MainDispatcherTestRule.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/NetworkSynchronizationHelper.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/statesetup/SetupAuthenticationState.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/storage/TestDatabaseInitialization.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/LoginRobot.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/HomeRobot.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt create mode 100644 hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashRobot.kt create mode 100644 hilt/hilt-app/.gitignore create mode 100644 hilt/hilt-app/build.gradle create mode 100644 hilt/hilt-app/consumer-rules.pro create mode 100644 hilt/hilt-app/proguard-rules.pro create mode 100644 hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/1.json create mode 100644 hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/2.json create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestUserDataLocalStorageModule.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/runner/HiltTestRunner.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeNavigationRobot.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/main/AndroidManifest.xml create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/TestShowcaseApplication.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/ComposeActivity.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreen.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreen.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/splash/SplashScreen.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/AppModule.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/BaseUrlModule.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/StorageModule.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/UserDataLocalStorageModule.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/session/SessionExpirationListenerImpl.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/LocalDatabase.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/SharedPreferencesManagerImpl.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/database/DatabaseInitialization.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImpl.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteDao.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteEntity.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/migation/Migration1To2.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/IntentCoordinator.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/ViewModelDelegate.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivity.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModel.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/SetTextIfNotSameObserver.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/FavouriteContentAdapter.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainActivity.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainViewModel.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/Event.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/VerticalSpaceItemDecoration.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewBindingAdapter.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewExtension.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/AsyncTaskExecutor.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/DefaultTaskExecutor.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/TaskExecutor.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivity.kt create mode 100644 hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModel.kt create mode 100644 hilt/hilt-app/src/main/res/drawable-v24/ic_compose_launcher_foreground.xml create mode 100644 hilt/hilt-app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 hilt/hilt-app/src/main/res/drawable/favorite_24.xml create mode 100644 hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml create mode 100644 hilt/hilt-app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 hilt/hilt-app/src/main/res/drawable/logout_24.xml create mode 100644 hilt/hilt-app/src/main/res/drawable/show_password.xml create mode 100644 hilt/hilt-app/src/main/res/layout/activity_authentication.xml create mode 100644 hilt/hilt-app/src/main/res/layout/activity_main.xml create mode 100644 hilt/hilt-app/src/main/res/layout/activity_splash.xml create mode 100644 hilt/hilt-app/src/main/res/layout/item_favourite_content.xml create mode 100644 hilt/hilt-app/src/main/res/menu/main.xml create mode 100644 hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher.xml create mode 100644 hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher_round.xml create mode 100644 hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 hilt/hilt-app/src/main/res/values-night/themes.xml create mode 100644 hilt/hilt-app/src/main/res/values/colors.xml create mode 100644 hilt/hilt-app/src/main/res/values/dimens.xml create mode 100644 hilt/hilt-app/src/main/res/values/strings.xml create mode 100644 hilt/hilt-app/src/main/res/values/themes.xml create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/UserDataLocalStorageTest.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricAuthActivityInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricLoginRobot.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt create mode 100644 hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModelTest.kt create mode 100644 hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/home/MainViewModelTest.kt create mode 100644 hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/shared/EventTest.kt create mode 100644 hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModelTest.kt create mode 100644 hilt/hilt-app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 hilt/hilt-app/src/test/resources/robolectric.properties create mode 100644 hilt/hilt-core/.gitignore create mode 100644 hilt/hilt-core/build.gradle create mode 100644 hilt/hilt-core/consumer-rules.pro create mode 100644 hilt/hilt-core/proguard-rules.pro create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCase.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/ContentRepository.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCase.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCase.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCase.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/CoreModule.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/LoggedInModuleInject.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCase.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LoginUseCase.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCase.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapter.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationListener.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtils.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/Optional.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/UnexpectedException.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapter.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/UserDataLocalStorage.kt create mode 100644 hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/content/FavouriteContentLocalStorage.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/ContentRepositoryTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineContentRepositoryTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineGetAllContentUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/di/TestCoreComponent.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LoginUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCaseTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapterTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtilsKtTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapterTest.kt create mode 100644 hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/testutil/AwaitElementEmitCount.kt create mode 100644 hilt/hilt-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeFavouriteContentLocalStorage.kt create mode 100644 hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeUserDataLocalStorage.kt create mode 100644 hilt/hilt-network-di-test-util/.gitignore create mode 100644 hilt/hilt-network-di-test-util/build.gradle create mode 100644 hilt/hilt-network-di-test-util/consumer-rules.pro create mode 100644 hilt/hilt-network-di-test-util/proguard-rules.pro create mode 100644 hilt/hilt-network-di-test-util/src/main/AndroidManifest.xml create mode 100644 hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/HttpsConfigurationModuleTemplate.kt create mode 100644 hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt create mode 100644 hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt create mode 100644 hilt/hilt-network/.gitignore create mode 100644 hilt/hilt-network/build.gradle create mode 100644 hilt/hilt-network/consumer-rules.pro create mode 100644 hilt/hilt-network/proguard-rules.pro create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverter.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSource.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceImpl.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginService.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/CredentialsRequest.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginResponse.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginStatusResponses.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSource.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImpl.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentResponse.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentService.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/BindsBaseOkHttpClient.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/HiltNetworkModule.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/OkhttpClientExtension.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionLessQualifier.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionQualifier.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderInterceptor.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderUtils.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionExpirationListener.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionLocalStorage.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/SessionAuthenticator.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/ExceptionWrapper.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/PlatformInterceptor.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/NetworkException.kt create mode 100644 hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/ParsingException.kt create mode 100644 hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverterTest.kt create mode 100644 hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceRefreshActionImplTest.kt create mode 100644 hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceTest.kt create mode 100644 hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImplTest.kt create mode 100644 hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/SessionExpirationTest.kt create mode 100644 hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/testutil/TestNetworkComponent.kt create mode 100644 hilt/hilt-network/src/test/resources/success_response_login.json create mode 100644 hilt/hilt-network/src/testFixtures/java/org/fnives/test/showcase/hilt/network/testutil/MockServerScenarioSetupExtensions.kt diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt index 4061051..3c24451 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt @@ -15,6 +15,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.koin.core.context.stopKoin +import org.koin.test.KoinTest import java.io.IOException /** @@ -23,7 +24,7 @@ import java.io.IOException * https://developer.android.com/training/data-storage/room/migrating-db-versions */ @RunWith(AndroidJUnit4::class) -open class MigrationToLatestInstrumentedSharedTest { +open class MigrationToLatestInstrumentedSharedTest : KoinTest { @get:Rule val helper = SharedMigrationTestRule(instrumentation = InstrumentationRegistry.getInstrumentation()) diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt index 1678675..798c9e7 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt @@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.home import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents -import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule import org.fnives.test.showcase.android.testutil.activity.safeClose import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule @@ -21,11 +20,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain -import org.junit.runner.RunWith import org.koin.test.KoinTest @Suppress("TestFunctionName") -@RunWith(AndroidJUnit4::class) open class MainActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt index d838949..36725e5 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt @@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.login import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents -import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule @@ -16,11 +15,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain -import org.junit.runner.RunWith import org.koin.test.KoinTest @Suppress("TestFunctionName") -@RunWith(AndroidJUnit4::class) open class AuthActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/app/build.gradle b/app/build.gradle index 7234e88..d18fe4a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,12 +41,8 @@ android { } sourceSets { - androidTest { -// assets.srcDirs += files("$projectDir/schemas".toString()) - } test { java.srcDirs += "src/robolectricTest/java" -// resources.srcDirs += files("$projectDir/schemas".toString()) } } diff --git a/build.gradle b/build.gradle index 40d384d..8318014 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ buildscript { ext.kotlin_version = "1.6.10" ext.detekt_version = "1.19.0" ext.navigation_version = "2.4.2" + ext.hilt_version = "2.40.5" repositories { mavenCentral() google() @@ -13,6 +14,7 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" + classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" } } diff --git a/hilt/hilt-app-shared-test/.gitignore b/hilt/hilt-app-shared-test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-app-shared-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/build.gradle b/hilt/hilt-app-shared-test/build.gradle new file mode 100644 index 0000000..740e553 --- /dev/null +++ b/hilt/hilt-app-shared-test/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 31 + + defaultConfig { + minSdk 21 + targetSdk 31 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + sourceSets { + main { + assets.srcDirs += files("$projectDir/../hilt-app/schemas".toString()) + resources.srcDirs += files("$projectDir/../hilt-app/schemas".toString()) + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +// since it itself contains the Test it doesn't have tests of it's own +disableTestTasks(this) + +dependencies { + implementation project(":hilt:hilt-app") + implementation project(':test-util-android') + implementation testFixtures(project(':hilt:hilt-core')) + implementation "com.google.dagger:hilt-android-testing:$hilt_version" + implementation project(':test-util-shared-robolectric') + api project(':hilt:hilt-network-di-test-util') + applyAppSharedTestDependenciesTo(this) +} \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/consumer-rules.pro b/hilt/hilt-app-shared-test/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-app-shared-test/proguard-rules.pro b/hilt/hilt-app-shared-test/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-app-shared-test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/src/main/AndroidManifest.xml b/hilt/hilt-app-shared-test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4053ef5 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/di/TestBaseUrlHolder.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/di/TestBaseUrlHolder.kt new file mode 100644 index 0000000..f95f1dc --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/di/TestBaseUrlHolder.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.test.shared.di + +import org.fnives.test.showcase.hilt.BuildConfig + +object TestBaseUrlHolder { + + var url = BuildConfig.BASE_URL +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt new file mode 100644 index 0000000..da60724 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt @@ -0,0 +1,79 @@ +package org.fnives.test.showcase.hilt.test.shared.storage.migration + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.android.testutil.SharedMigrationTestRule +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity +import org.fnives.test.showcase.hilt.storage.migation.Migration1To2 +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +/** + * reference: + * https://medium.com/androiddevelopers/testing-room-migrations-be93cdb0d975 + * https://developer.android.com/training/data-storage/room/migrating-db-versions + */ +@RunWith(AndroidJUnit4::class) +open class MigrationToLatestInstrumentedSharedTest { + + @get:Rule + val helper = SharedMigrationTestRule(instrumentation = InstrumentationRegistry.getInstrumentation()) + + private fun getMigratedRoomDatabase(): LocalDatabase { + val database: LocalDatabase = Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + LocalDatabase::class.java, + TEST_DB + ) + .addMigrations(Migration1To2()) + .build() + // close the database and release any stream resources when the test finishes + helper.closeWhenFinished(database) + return database + } + + @Test + @Throws(IOException::class) + open fun migrate1To2() { + val expectedEntities = setOf( + FavouriteEntity("123"), + FavouriteEntity("124"), + FavouriteEntity("125") + ) + val version1DB = helper.createDatabase( + name = TEST_DB, + version = 1 + ) + version1DB.run { + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (\"123\")") + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (124)") + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (125)") + } + version1DB.close() + + val version2DB = helper.runMigrationsAndValidate( + name = TEST_DB, + version = 2, + validateDroppedTables = true, + Migration1To2() + ) + version2DB.close() + + val favouriteDao = getMigratedRoomDatabase().favouriteDao + + val entities = runBlocking { favouriteDao.get().first() }.toSet() + + Assert.assertEquals(expectedEntities, entities) + } + + companion object { + private const val TEST_DB = "migration-test" + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/MockServerScenarioSetupTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/MockServerScenarioSetupTestRule.kt new file mode 100644 index 0000000..ed48ae4 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/MockServerScenarioSetupTestRule.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils + +import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class MockServerScenarioSetupTestRule : TestRule { + + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun apply(base: Statement, description: Description): Statement = createStatement(base) + + private fun createStatement(base: Statement) = object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + before() + try { + base.evaluate() + } finally { + after() + } + } + } + + private fun before() { + val (mockServerScenarioSetup, url) = HttpsConfigurationModuleTemplate.startWithHTTPSMockWebServer() + TestBaseUrlHolder.url = url + this.mockServerScenarioSetup = mockServerScenarioSetup + } + + private fun after() { + mockServerScenarioSetup.stop() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/AsyncDiffUtilInstantTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/AsyncDiffUtilInstantTestRule.kt new file mode 100644 index 0000000..06bd99f --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/AsyncDiffUtilInstantTestRule.kt @@ -0,0 +1,32 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor +import org.fnives.test.showcase.hilt.ui.shared.executor.TaskExecutor +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Similar Test Rule to InstantTaskExecutorRule just for the [AsyncTaskExecutor] to make AsyncDiffUtil synchronized. + */ +class AsyncDiffUtilInstantTestRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + AsyncTaskExecutor.delegate = object : TaskExecutor { + override fun executeOnDiskIO(runnable: Runnable) { + runnable.run() + } + + override fun postToMainThread(runnable: Runnable) { + runnable.run() + } + } + + base.evaluate() + + AsyncTaskExecutor.delegate = null + } + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/DatabaseDispatcherTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/DatabaseDispatcherTestRule.kt new file mode 100644 index 0000000..9d98b31 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/DatabaseDispatcherTestRule.kt @@ -0,0 +1,52 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceNotIdle +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.awaitIdlingResources +import org.fnives.test.showcase.android.testutil.synchronization.runOnUIAwaitOnCurrent +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +@OptIn(ExperimentalCoroutinesApi::class) +class DatabaseDispatcherTestRule : TestRule { + + lateinit var testDispatcher: TestDispatcher + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + val dispatcher = StandardTestDispatcher() + testDispatcher = dispatcher + TestDatabaseInitialization.dispatcher = dispatcher + base.evaluate() + } + } + + fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent { + testDispatcher.advanceUntilIdleWithIdlingResources() + } + + fun advanceUntilIdle() = runOnUIAwaitOnCurrent { + testDispatcher.scheduler.advanceUntilIdle() + } + + fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent { + testDispatcher.scheduler.advanceTimeBy(delayInMillis) + } + + companion object { + fun TestDispatcher.advanceUntilIdleWithIdlingResources() { + scheduler.advanceUntilIdle() // advance until a request is sent + while (anyResourceNotIdle()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + scheduler.advanceUntilIdle() // run coroutines after request is finished + } + scheduler.advanceUntilIdle() + } + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/MainDispatcherTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/MainDispatcherTestRule.kt new file mode 100644 index 0000000..7e1e4b3 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/MainDispatcherTestRule.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule as LibMainDispatcherTestRule + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherTestRule(useStandard: Boolean = true) : LibMainDispatcherTestRule(useStandard) { + + override fun onTestDispatcherInitialized(testDispatcher: TestDispatcher) { + TestDatabaseInitialization.dispatcher = testDispatcher + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/NetworkSynchronizationHelper.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/NetworkSynchronizationHelper.kt new file mode 100644 index 0000000..8ecb76f --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/NetworkSynchronizationHelper.kt @@ -0,0 +1,23 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable +import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization +import javax.inject.Inject + +class NetworkSynchronizationHelper @Inject constructor(private val networkSynchronization: NetworkSynchronization) { + + private val compositeDisposable = CompositeDisposable() + + fun setup() { + networkSynchronization.networkIdlingResources().map { + IdlingResourceDisposable(it) + }.forEach { + compositeDisposable.add(it) + } + } + + fun dispose() { + compositeDisposable.dispose() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/statesetup/SetupAuthenticationState.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/statesetup/SetupAuthenticationState.kt new file mode 100644 index 0000000..2479e25 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/statesetup/SetupAuthenticationState.kt @@ -0,0 +1,69 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.statesetup + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import androidx.test.runner.intent.IntentStubberRegistry +import org.fnives.test.showcase.android.testutil.activity.safeClose +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.ui.auth.LoginRobot +import org.fnives.test.showcase.hilt.test.shared.ui.home.HomeRobot +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.koin.test.KoinTest + +object SetupAuthenticationState : KoinTest { + + fun setupLogin( + mainDispatcherTestRule: MainDispatcherTestRule, + mockServerScenarioSetup: MockServerScenarioSetup, + resetIntents: Boolean = true, + ) { + resetIntentsIfNeeded(resetIntents) { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b")) + val activityScenario = ActivityScenario.launch(AuthActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + val loginRobot = LoginRobot() + loginRobot.setupIntentResults() + loginRobot + .setPassword("b") + .setUsername("a") + .clickOnLogin() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + activityScenario.safeClose() + } + } + + fun setupLogout( + mainDispatcherTestRule: MainDispatcherTestRule, + resetIntents: Boolean = true, + ) { + resetIntentsIfNeeded(resetIntents) { + val activityScenario = ActivityScenario.launch(MainActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + val homeRobot = HomeRobot() + homeRobot.setupIntentResults() + homeRobot.clickSignOut() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + activityScenario.safeClose() + } + } + + private fun resetIntentsIfNeeded(resetIntents: Boolean, action: () -> Unit) { + val wasInitialized = IntentStubberRegistry.isLoaded() + if (!wasInitialized) { + Intents.init() + } + action() + Intents.release() + if (resetIntents && wasInitialized) { + Intents.init() + } + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/storage/TestDatabaseInitialization.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/storage/TestDatabaseInitialization.kt new file mode 100644 index 0000000..9a04896 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/storage/TestDatabaseInitialization.kt @@ -0,0 +1,48 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.storage + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import org.fnives.test.showcase.hilt.di.StorageModule +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import javax.inject.Singleton + +/** + * Reloads the Database module, so it uses the inMemory database with the switched out Executors. + * + * This is needed so in AndroidTests not a real File based device is used. + * This speeds tests up, and isolates them better, there will be no junk in the Database file from previous tests. + */ +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [StorageModule::class] +) +object TestDatabaseInitialization { + + var dispatcher: CoroutineDispatcher? = null + + @Suppress("ObjectPropertyName") + private val _dispatcher: CoroutineDispatcher + get() = dispatcher ?: throw IllegalStateException("TestDispatcher is not initialized") + + fun create(context: Context, dispatcher: CoroutineDispatcher = this._dispatcher): LocalDatabase { + val executor = dispatcher.asExecutor() + return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java) + .setTransactionExecutor(executor) + .setQueryExecutor(executor) + .allowMainThreadQueries() + .build() + } + + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + create(context) +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt new file mode 100644 index 0000000..ac61133 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt @@ -0,0 +1,41 @@ +package org.fnives.test.showcase.hilt.test.shared.ui + +import dagger.hilt.android.testing.HiltAndroidRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.NetworkSynchronizationHelper +import org.junit.After +import org.junit.Before +import org.junit.Rule +import javax.inject.Inject + +open class NetworkSynchronizedActivityTest { + + @Inject + lateinit var networkSynchronizationHelper: NetworkSynchronizationHelper + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun setup() { + setupBeforeInjection() + + hiltRule.inject() + networkSynchronizationHelper.setup() + setupAfterInjection() + } + + open fun setupBeforeInjection() { + } + + open fun setupAfterInjection() { + } + + @After + fun tearDown() { + networkSynchronizationHelper.dispose() + additionalTearDown() + } + + open fun additionalTearDown() { + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt new file mode 100644 index 0000000..f9b7327 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt @@ -0,0 +1,138 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.auth + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@Suppress("TestFunctionName") +open class AuthActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { + + private lateinit var activityScenario: ActivityScenario + + private val mainDispatcherTestRule = MainDispatcherTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + private lateinit var robot: LoginRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mockServerScenarioSetupTestRule) + .around(mainDispatcherTestRule) + .around(SafeCloseActivityRule { activityScenario }) + .around(ScreenshotRule("test-showcase")) + + override fun setupAfterInjection() { + Intents.init() + robot = LoginRobot() + } + + override fun additionalTearDown() { + Intents.release() + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertNavigatedToHome() + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/LoginRobot.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/LoginRobot.kt new file mode 100644 index 0000000..c42bd39 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/LoginRobot.kt @@ -0,0 +1,94 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.auth + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsNotShown +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsShownWithText +import org.fnives.test.showcase.android.testutil.viewaction.progressbar.ReplaceProgressBarDrawableToStatic +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.hamcrest.core.IsNot.not + +class LoginRobot { + + fun setupIntentResults() { + Intents.intending(hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + /** + * Needed because Espresso idling waits until mainThread is idle. + * + * However, ProgressBar keeps the main thread active since it's animating. + * + * Another solution is described here: https://proandroiddev.com/progressbar-animations-with-espresso-57f826102187 + * In short they replace the inflater to remove animations, by using custom test runner. + */ + fun replaceProgressBar() = apply { + onView(withId(R.id.loading_indicator)).perform(ReplaceProgressBarDrawableToStatic()) + } + + fun setUsername(username: String): LoginRobot = apply { + onView(withId(R.id.user_edit_text)) + .perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard()) + } + + fun setPassword(password: String): LoginRobot = apply { + onView(withId(R.id.password_edit_text)) + .perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard()) + } + + fun clickOnLogin() = apply { + replaceProgressBar() + onView(withId(R.id.login_cta)) + .perform(ViewActions.click()) + } + + fun assertPassword(password: String) = apply { + onView(withId((R.id.password_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(password))) + } + + fun assertUsername(username: String) = apply { + onView(withId((R.id.user_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(username))) + } + + fun assertErrorIsShown(@StringRes stringResID: Int) = apply { + assertSnackBarIsShownWithText(stringResID) + } + + fun assertLoadingBeforeRequests() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(isDisplayed())) + } + + fun assertNotLoading() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(not(isDisplayed()))) + } + + fun assertErrorIsNotShown() = apply { + assertSnackBarIsNotShown() + } + + fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/HomeRobot.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/HomeRobot.kt new file mode 100644 index 0000000..a0a024f --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/HomeRobot.kt @@ -0,0 +1,123 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.home + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasChildCount +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withChild +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withParent +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.android.testutil.viewaction.imageview.WithDrawable +import org.fnives.test.showcase.android.testutil.viewaction.recycler.RemoveItemAnimations +import org.fnives.test.showcase.android.testutil.viewaction.swiperefresh.PullToRefresh +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.FavouriteContent +import org.hamcrest.Matchers.allOf + +class HomeRobot { + + /** + * Needed because Espresso idling sometimes not in sync with RecyclerView's animation. + * So we simply remove the item animations, the animations should be disabled anyway for test. + * + * Reference: https://github.com/android/android-test/issues/223 + */ + fun removeItemAnimations() = apply { + Espresso.onView(withId(R.id.recycler)).perform(RemoveItemAnimations()) + } + + fun assertToolbarIsShown() = apply { + Espresso.onView(withId(R.id.toolbar)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + } + + fun setupIntentResults() { + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + fun assertNavigatedToAuth() = apply { + Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun assertDidNotNavigateToAuth() = apply { + notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun clickSignOut(setupIntentResults: Boolean = true) = apply { + if (setupIntentResults) { + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + Espresso.onView(withId(R.id.logout_cta)).perform(click()) + } + + fun assertContainsItem(index: Int, item: FavouriteContent) = apply { + removeItemAnimations() + val isFavouriteResourceId = if (item.isFavourite) { + R.drawable.favorite_24 + } else { + R.drawable.favorite_border_24 + } + Espresso.onView(withId(R.id.recycler)) + .perform(RecyclerViewActions.scrollToPosition(index)) + + Espresso.onView( + allOf( + withChild(allOf(withText(item.content.title), withId(R.id.title))), + withChild(allOf(withText(item.content.description), withId(R.id.description))), + withChild(allOf(withId(R.id.favourite_cta), WithDrawable(isFavouriteResourceId))) + ) + ) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + } + + fun clickOnContentItem(index: Int, item: Content) = apply { + removeItemAnimations() + Espresso.onView(withId(R.id.recycler)) + .perform(RecyclerViewActions.scrollToPosition(index)) + + Espresso.onView( + allOf( + withId(R.id.favourite_cta), + withParent( + allOf( + withChild(allOf(withText(item.title), withId(R.id.title))), + withChild(allOf(withText(item.description), withId(R.id.description))) + ) + ) + ) + ) + .perform(click()) + } + + fun swipeRefresh() = apply { + Espresso.onView(withId(R.id.swipe_refresh_layout)).perform(PullToRefresh()) + } + + fun assertContainsNoItems() = apply { + removeItemAnimations() + Espresso.onView(withId(R.id.recycler)) + .check(matches(hasChildCount(0))) + } + + fun assertContainsError() = apply { + Espresso.onView(withId(R.id.error_message)) + .check(matches(allOf(isDisplayed(), withText(R.string.something_went_wrong)))) + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt new file mode 100644 index 0000000..128f468 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt @@ -0,0 +1,214 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.home + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule +import org.fnives.test.showcase.android.testutil.activity.safeClose +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.AsyncDiffUtilInstantTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@Suppress("TestFunctionName") +open class MainActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { + + private lateinit var activityScenario: ActivityScenario + + private val mainDispatcherTestRule = MainDispatcherTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + private lateinit var robot: HomeRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mockServerScenarioSetupTestRule) + .around(mainDispatcherTestRule) + .around(AsyncDiffUtilInstantTestRule()) + .around(SafeCloseActivityRule { activityScenario }) + .around(ScreenshotRule("test-showcase")) + + override fun setupAfterInjection() { + super.setupAfterInjection() + robot = HomeRobot() + setupLogin(mainDispatcherTestRule, mockServerScenarioSetup) + Intents.init() + } + + override fun additionalTearDown() { + super.additionalTearDown() + Intents.release() + } + + /** GIVEN initialized MainActivity WHEN signout is clicked THEN user is signed out */ + @Test + fun signOutClickedResultsInNavigation() { + mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.clickSignOut() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertNavigatedToAuth() + } + + /** GIVEN success response WHEN data is returned THEN it is shown on the ui */ + @Test + fun successfulDataLoadingShowsTheElementsOnTheUI() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + ContentData.contentSuccess.forEachIndexed { index, content -> + robot.assertContainsItem(index, FavouriteContent(content, false)) + } + robot.assertDidNotNavigateToAuth() + } + + /** GIVEN success response WHEN item is clicked THEN ui is updated */ + @Test + fun clickingOnListElementUpdatesTheElementsFavouriteState() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true) + robot.assertContainsItem(0, expectedItem) + .assertDidNotNavigateToAuth() + } + + /** GIVEN success response WHEN item is clicked THEN ui is updated even if activity is recreated */ + @Test + fun elementFavouritedIsKeptEvenIfActivityIsRecreated() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true) + + activityScenario.safeClose() + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertContainsItem(0, expectedItem) + .assertDidNotNavigateToAuth() + } + + /** GIVEN success response WHEN item is clicked then clicked again THEN ui is updated */ + @Test + fun clickingAnElementMultipleTimesProperlyUpdatesIt() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false) + robot.assertContainsItem(0, expectedItem) + .assertDidNotNavigateToAuth() + } + + /** GIVEN error response WHEN loaded THEN error is Shown */ + @Test + fun networkErrorResultsInUIErrorStateShown() { + mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertContainsNoItems() + .assertContainsError() + .assertDidNotNavigateToAuth() + } + + /** GIVEN error response then success WHEN retried THEN success is shown */ + @Test + fun retryingFromErrorStateAndSucceedingShowsTheData() { + mockServerScenarioSetup.setScenario( + ContentScenario.Error(usingRefreshedToken = false) + .then(ContentScenario.Success(usingRefreshedToken = false)) + ) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.swipeRefresh() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loopMainThreadFor(2000L) + + ContentData.contentSuccess.forEachIndexed { index, content -> + robot.assertContainsItem(index, FavouriteContent(content, false)) + } + robot.assertDidNotNavigateToAuth() + } + + /** GIVEN success then error WHEN retried THEN error is shown */ + @Test + fun errorIsShownIfTheDataIsFetchedAndErrorIsReceived() { + mockServerScenarioSetup.setScenario( + ContentScenario.Success(usingRefreshedToken = false) + .then(ContentScenario.Error(usingRefreshedToken = false)) + ) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.swipeRefresh() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot + .assertContainsError() + .assertContainsNoItems() + .assertDidNotNavigateToAuth() + } + + /** GIVEN unauthenticated then success WHEN loaded THEN success is shown */ + @Test + fun authenticationIsHandledWithASingleLoading() { + mockServerScenarioSetup.setScenario( + ContentScenario.Unauthorized(usingRefreshedToken = false) + .then(ContentScenario.Success(usingRefreshedToken = true)) + ) + .setScenario(RefreshTokenScenario.Success) + + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + ContentData.contentSuccess.forEachIndexed { index, content -> + robot.assertContainsItem(index, FavouriteContent(content, false)) + } + robot.assertDidNotNavigateToAuth() + } + + /** GIVEN unauthenticated then error WHEN loaded THEN navigated to auth */ + @Test + fun sessionExpirationResultsInNavigation() { + mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false)) + .setScenario(RefreshTokenScenario.Error) + + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertNavigatedToAuth() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt new file mode 100644 index 0000000..aeadbaa --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt @@ -0,0 +1,101 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.splash + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin +import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogout +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.splash.SplashActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@Suppress("TestFunctionName") +open class SplashActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { + + private lateinit var activityScenario: ActivityScenario + + private val mainDispatcherTestRule = MainDispatcherTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + + private lateinit var robot: SplashRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mainDispatcherTestRule) + .around(mockServerScenarioSetupTestRule) + .around(SafeCloseActivityRule { activityScenario }) + .around(ScreenshotRule("test-showcase")) + + override fun setupAfterInjection() { + Intents.init() + robot = SplashRobot() + } + + override fun additionalTearDown() { + Intents.release() + } + + /** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */ + @Test + fun loggedInStateNavigatesToHome() { + setupLogin(mainDispatcherTestRule, mockServerScenarioSetup) + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(501) + + robot.assertHomeIsStarted() + .assertAuthIsNotStarted() + } + + /** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */ + @Test + fun loggedOutStatesNavigatesToAuthentication() { + setupLogout(mainDispatcherTestRule) + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(501) + + robot.assertAuthIsStarted() + .assertHomeIsNotStarted() + } + + /** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */ + @Test + fun loggedOutStatesNotEnoughTime() { + setupLogout(mainDispatcherTestRule) + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(500) + + robot.assertAuthIsNotStarted() + .assertHomeIsNotStarted() + } + + /** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */ + @Test + fun loggedInStatesNotEnoughTime() { + setupLogin(mainDispatcherTestRule, mockServerScenarioSetup) + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(500) + + robot.assertHomeIsNotStarted() + .assertAuthIsNotStarted() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashRobot.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashRobot.kt new file mode 100644 index 0000000..9cb0a27 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashRobot.kt @@ -0,0 +1,36 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.splash + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.hilt.ui.home.MainActivity + +class SplashRobot { + + fun setupIntentResults() { + Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + fun assertHomeIsStarted() = apply { + Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertHomeIsNotStarted() = apply { + notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertAuthIsStarted() = apply { + Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun assertAuthIsNotStarted() = apply { + notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } +} diff --git a/hilt/hilt-app/.gitignore b/hilt/hilt-app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-app/build.gradle b/hilt/hilt-app/build.gradle new file mode 100644 index 0000000..1dd1fd2 --- /dev/null +++ b/hilt/hilt-app/build.gradle @@ -0,0 +1,127 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + compileSdk 31 + + defaultConfig { + applicationId "org.fnives.test.showcase.hilt" + minSdk 21 + targetSdk 31 + versionCode 1 + versionName "1.0" + buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"' + + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + } + + testInstrumentationRunner "org.fnives.test.showcase.hilt.runner.HiltTestRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + flavorDimensions 'di' + + buildFeatures { + viewBinding true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = project.androidx_compose_version + } + + sourceSets { + test { + java.srcDirs += "src/robolectricTest/java" + } + } + + // needed for androidTest + packagingOptions { + exclude 'META-INF/LGPL2.1' + exclude 'META-INF/AL2.0' + exclude 'META-INF/LICENSE.md' + exclude 'META-INF/LICENSE-notice.md' + } +} + +hilt { + enableAggregatingTask = true +} + +afterEvaluate { + // making sure the :mockserver is assembled after :clean when running tests + testDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') + testReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') +} + +dependencies { + implementation "androidx.core:core-ktx:$androidx_core_version" + implementation "androidx.appcompat:appcompat:$androidx_appcompat_version" + implementation "com.google.android.material:material:$androidx_material_version" + implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version" + implementation "androidx.constraintlayout:constraintlayout-compose:$androidx_compose_constraintlayout_version" + implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version" + + implementation "androidx.activity:activity-compose:$activity_ktx_version" + implementation "androidx.navigation:navigation-compose:$androidx_navigation" + + implementation "androidx.compose.ui:ui:$androidx_compose_version" + implementation "androidx.compose.ui:ui-tooling:$androidx_compose_version" + implementation "androidx.compose.foundation:foundation:$androidx_compose_version" + implementation "androidx.compose.material:material:$androidx_compose_version" + implementation "androidx.compose.animation:animation-graphics:$androidx_compose_version" + implementation "com.google.accompanist:accompanist-insets:$google_accompanist_version" + implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist_version" + + // Hilt + implementation "com.google.dagger:hilt-android:$hilt_version" + kapt "com.google.dagger:hilt-compiler:$hilt_version" + + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:$room_version" + implementation "androidx.room:room-ktx:$room_version" + + implementation "io.coil-kt:coil:$coil_version" + implementation "io.coil-kt:coil-compose:$coil_version" + + implementation project(":hilt:hilt-core") + + applyAppTestDependenciesTo(this) + applyComposeTestDependenciesTo(this) + + androidTestImplementation project(':mockserver') + + testImplementation project(':test-util-junit5-android') + testImplementation project(':test-util-shared-robolectric') + testImplementation project(':test-util-android') + androidTestImplementation project(':test-util-android') + androidTestImplementation project(':test-util-shared-android') + + testImplementation testFixtures(project(":hilt:hilt-core")) + androidTestImplementation testFixtures(project(":hilt:hilt-core")) + + testImplementation project(':hilt:hilt-app-shared-test') + androidTestImplementation project(':hilt:hilt-app-shared-test') + + testImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + kaptTest "com.google.dagger:hilt-compiler:$hilt_version" + androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + kaptAndroidTest "com.google.dagger:hilt-compiler:$hilt_version" +} + +apply from: '../../gradlescripts/pull-screenshots.gradle' \ No newline at end of file diff --git a/hilt/hilt-app/consumer-rules.pro b/hilt/hilt-app/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-app/proguard-rules.pro b/hilt/hilt-app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/1.json b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/1.json new file mode 100644 index 0000000..c482b98 --- /dev/null +++ b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/1.json @@ -0,0 +1,34 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "36d840e89667f36e0c265593da36fe23", + "entities": [ + { + "tableName": "FavouriteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contentId` TEXT NOT NULL, PRIMARY KEY(`contentId`))", + "fields": [ + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contentId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '36d840e89667f36e0c265593da36fe23')" + ] + } +} \ No newline at end of file diff --git a/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/2.json b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/2.json new file mode 100644 index 0000000..de49ad7 --- /dev/null +++ b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/2.json @@ -0,0 +1,34 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "3723fe73a9d3dc43de8ff3e52ec46490", + "entities": [ + { + "tableName": "FavouriteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`content_id` TEXT NOT NULL, PRIMARY KEY(`content_id`))", + "fields": [ + { + "fieldPath": "contentId", + "columnName": "content_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "content_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3723fe73a9d3dc43de8ff3e52ec46490')" + ] + } +} \ No newline at end of file diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt new file mode 100644 index 0000000..d90bfb7 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BindsBaseOkHttpClient::class] +) +object HttpsConfigurationModule { + + @Provides + @Singleton + @SessionLessQualifier + fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + HttpsConfigurationModuleTemplate.bindsBaseOkHttpClient(enableLogging, platformInterceptor) +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt new file mode 100644 index 0000000..cde8d0e --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BaseUrlModule::class] +) +object TestBaseUrlModule { + + @Provides + fun provideBaseUrl(): String = TestBaseUrlHolder.url +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt new file mode 100644 index 0000000..687707e --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [StorageModule::class] +) +object TestDatabaseInitializationModule { + + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + TestDatabaseInitialization.provideLocalDatabase(context) +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestUserDataLocalStorageModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestUserDataLocalStorageModule.kt new file mode 100644 index 0000000..3d1cc88 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestUserDataLocalStorageModule.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [UserDataLocalStorageModule::class] +) +object TestUserDataLocalStorageModule { + + var replacement: UserDataLocalStorage? = null + + @Singleton + @Provides + fun provideUserDataLocalStorage( + sharedPreferencesManagerImpl: SharedPreferencesManagerImpl, + ): UserDataLocalStorage = replacement ?: sharedPreferencesManagerImpl +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/runner/HiltTestRunner.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/runner/HiltTestRunner.kt new file mode 100644 index 0000000..6807109 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/runner/HiltTestRunner.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.runner + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt new file mode 100644 index 0000000..45ce6d9 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.storage.migration + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.hilt.test.shared.storage.migration.MigrationToLatestInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MigrationToLatestInstrumentedTest : MigrationToLatestInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..ebef811 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.auth.AuthActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AuthActivityInstrumentedTest : AuthActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt new file mode 100644 index 0000000..6ece289 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -0,0 +1,199 @@ +package org.fnives.test.showcase.hilt.ui.compose + +import androidx.compose.ui.test.MainTestClock +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.compose.screen.AppNavigation +import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage +import org.fnives.test.showcase.hilt.di.TestUserDataLocalStorageModule +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.DatabaseDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { + + private val composeTestRule = createComposeRule() + private val stateRestorationTester = StateRestorationTester(composeTestRule) + + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + private val dispatcherTestRule = DatabaseDispatcherTestRule() + private lateinit var robot: ComposeLoginRobot + private lateinit var navigationRobot: ComposeNavigationRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mockServerScenarioSetupTestRule) + .around(dispatcherTestRule) + .around(composeTestRule) + .around(ScreenshotRule("test-showcase-compose")) + + override fun setupBeforeInjection() { + TestUserDataLocalStorageModule.replacement = FakeUserDataLocalStorage() + } + + override fun setupAfterInjection() { + stateRestorationTester.setContent { + AppNavigation() + } + robot = ComposeLoginRobot(composeTestRule) + navigationRobot = ComposeNavigationRobot(composeTestRule) + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan") + ) + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + + navigationRobot.assertAuthScreen() + robot.setPassword("alma") + .setUsername("banan") + .assertUsername("banan") + .assertPassword("alma") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.awaitIdlingResources() + navigationRobot.assertHomeScreen() + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + + robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + + robot + .setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(password = "alma", username = "banan") + ) + + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan") + ) + + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN username and password WHEN restoring THEN username and password fields contain the same text */ + @Test + fun restoringContentShowPreviousCredentials() { + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + stateRestorationTester.emulateSavedInstanceStateRestore() + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) // ensure all time based operation run + + navigationRobot.assertAuthScreen() + robot.assertUsername("alma") + .assertPassword("banan") + } + + companion object { + private const val SPLASH_DELAY = 600L + + // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 + /** + * Await the idling resource on a different thread while looping main. + */ + fun MainTestClock.awaitIdlingResources() { + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L)) + + advanceTimeByFrame() + } + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt new file mode 100644 index 0000000..913c350 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt @@ -0,0 +1,52 @@ +package org.fnives.test.showcase.hilt.ui.compose + +import android.content.Context +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreenTag + +class ComposeLoginRobot( + composeTestRule: ComposeTestRule, +) : ComposeTestRule by composeTestRule { + + fun setUsername(username: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) + } + + fun setPassword(password: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) + } + + fun assertPassword(password: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() + onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) + } + + fun assertUsername(username: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) + } + + fun clickOnLogin(): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.LoginButton).performClick() + } + + fun assertLoading(): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() + } + + fun assertNotLoading(): ComposeLoginRobot = apply { + onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) + } + + fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.LoginError) + .assertTextContains(ApplicationProvider.getApplicationContext().resources.getString(stringId)) + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeNavigationRobot.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeNavigationRobot.kt new file mode 100644 index 0000000..d4ac4df --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeNavigationRobot.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.ui.compose + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import org.fnives.test.showcase.hilt.compose.screen.AppNavigationTag + +class ComposeNavigationRobot( + private val composeTestRule: ComposeTestRule, +) { + + fun assertHomeScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists() + } + + fun assertAuthScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists() + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt new file mode 100644 index 0000000..e71b267 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.home + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.home.MainActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class MainActivityInstrumentedTest : MainActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt new file mode 100644 index 0000000..74b6ace --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.splash.SplashActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class SplashActivityInstrumentedTest : SplashActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/main/AndroidManifest.xml b/hilt/hilt-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..77b5522 --- /dev/null +++ b/hilt/hilt-app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/TestShowcaseApplication.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/TestShowcaseApplication.kt new file mode 100644 index 0000000..038bce3 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/TestShowcaseApplication.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.hilt + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class TestShowcaseApplication : Application() diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/ComposeActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/ComposeActivity.kt new file mode 100644 index 0000000..211cf82 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/ComposeActivity.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.hilt.compose + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import com.google.accompanist.insets.ProvideWindowInsets +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.compose.screen.AppNavigation + +@AndroidEntryPoint +class ComposeActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TestShowCaseApp() + } + } +} + +@Composable +fun TestShowCaseApp() { + ProvideWindowInsets { + MaterialTheme { + AppNavigation() + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt new file mode 100644 index 0000000..a383d08 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt @@ -0,0 +1,81 @@ +package org.fnives.test.showcase.hilt.compose.screen + +import androidx.compose.foundation.background +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavOptions +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.delay +import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreen +import org.fnives.test.showcase.hilt.compose.screen.auth.rememberAuthScreenState +import org.fnives.test.showcase.hilt.compose.screen.home.HomeScreen +import org.fnives.test.showcase.hilt.compose.screen.home.rememberHomeScreenState +import org.fnives.test.showcase.hilt.compose.screen.splash.SplashScreen +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase + +@Composable +fun AppNavigation( + isUserLogeInUseCase: IsUserLoggedInUseCase = AppNavigationEntryPoint.get().isUserLoggedInUseCase +) { + val navController = rememberNavController() + + LaunchedEffect(isUserLogeInUseCase) { + val loginStateRoute = if (isUserLogeInUseCase.invoke()) RouteTag.HOME else RouteTag.AUTH + if (navController.currentDestination?.route == loginStateRoute) return@LaunchedEffect + delay(500) + navController.navigate( + route = loginStateRoute, + navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.SPLASH, inclusive = true).build() + ) + } + + NavHost( + navController, + startDestination = RouteTag.SPLASH, + modifier = Modifier.background(MaterialTheme.colors.surface) + ) { + composable(RouteTag.SPLASH) { SplashScreen() } + composable(RouteTag.AUTH) { + AuthScreen( + modifier = Modifier.testTag(AppNavigationTag.AuthScreen), + authScreenState = rememberAuthScreenState( + onLoginSuccess = { + navController.navigate( + route = RouteTag.HOME, + navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.AUTH, inclusive = true).build() + ) + } + ) + ) + } + composable(RouteTag.HOME) { + HomeScreen( + modifier = Modifier.testTag(AppNavigationTag.HomeScreen), + homeScreenState = rememberHomeScreenState( + onLogout = { + navController.navigate( + route = RouteTag.AUTH, + navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.HOME, inclusive = true).build() + ) + } + ) + ) + } + } +} + +object RouteTag { + const val HOME = "Home" + const val AUTH = "Auth" + const val SPLASH = "Splash" +} + +object AppNavigationTag { + const val AuthScreen = "AppNavigationTag.AuthScreen" + const val HomeScreen = "AppNavigationTag.HomeScreen" +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt new file mode 100644 index 0000000..d04c7a1 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.compose.screen + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase + +object AppNavigationEntryPoint { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AppNavigationDependencies { + val isUserLoggedInUseCase: IsUserLoggedInUseCase + } + + @Composable + fun get(): AppNavigationDependencies { + val context = LocalContext.current.applicationContext + return remember { EntryPoints.get(context, AppNavigationDependencies::class.java) } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt new file mode 100644 index 0000000..3193ef8 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.compose.screen.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.login.LoginUseCase + +object AuthEntryPoint { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AuthDependencies { + val loginUseCase: LoginUseCase + } + + @Composable + fun get(): AuthDependencies { + val context = LocalContext.current.applicationContext + return remember { EntryPoints.get(context, AuthDependencies::class.java) } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreen.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreen.kt new file mode 100644 index 0000000..8e8dab4 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreen.kt @@ -0,0 +1,211 @@ +package org.fnives.test.showcase.hilt.compose.screen.auth + +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import com.google.accompanist.insets.statusBarsPadding +import org.fnives.test.showcase.hilt.R + +@Composable +fun AuthScreen( + modifier: Modifier = Modifier, + authScreenState: AuthScreenState = rememberAuthScreenState() +) { + ConstraintLayout(modifier.fillMaxSize()) { + val (title, credentials, snackbar, loading, login) = createRefs() + Title( + modifier = Modifier + .statusBarsPadding() + .constrainAs(title) { top.linkTo(parent.top) } + ) + CredentialsFields( + authScreenState = authScreenState, + modifier = Modifier.constrainAs(credentials) { + top.linkTo(title.bottom) + bottom.linkTo(login.top) + } + ) + Snackbar( + authScreenState = authScreenState, + modifier = Modifier.constrainAs(snackbar) { + bottom.linkTo(login.top) + } + ) + if (authScreenState.loading) { + CircularProgressIndicator( + Modifier + .testTag(AuthScreenTag.LoadingIndicator) + .constrainAs(loading) { + bottom.linkTo(login.top) + centerHorizontallyTo(parent) + } + ) + } + LoginButton( + modifier = Modifier + .constrainAs(login) { bottom.linkTo(parent.bottom) } + .padding(16.dp), + onClick = { authScreenState.onLogin() } + ) + } +} + +@Composable +private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + UsernameField(authScreenState) + PasswordField(authScreenState) + } +} + +@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi::class) +@Composable +private fun PasswordField(authScreenState: AuthScreenState) { + var passwordVisible by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = authScreenState.password, + label = { Text(text = stringResource(id = R.string.password)) }, + placeholder = { Text(text = stringResource(id = R.string.password)) }, + trailingIcon = { + val image = AnimatedImageVector.animatedVectorResource(R.drawable.show_password) + Icon( + painter = rememberAnimatedVectorPainter(image, passwordVisible), + contentDescription = null, + modifier = Modifier + .clickable { passwordVisible = !passwordVisible } + .testTag(AuthScreenTag.PasswordVisibilityToggle) + ) + }, + onValueChange = { authScreenState.onPasswordChanged(it) }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Password + ), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + authScreenState.onLogin() + }), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .testTag(AuthScreenTag.PasswordInput) + ) +} + +@Composable +private fun UsernameField(authScreenState: AuthScreenState) { + OutlinedTextField( + value = authScreenState.username, + label = { Text(text = stringResource(id = R.string.username)) }, + placeholder = { Text(text = stringResource(id = R.string.username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + onValueChange = { authScreenState.onUsernameChanged(it) }, + modifier = Modifier + .fillMaxWidth() + .testTag(AuthScreenTag.UsernameInput) + ) +} + +@Composable +private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { + val snackbarState = remember { SnackbarHostState() } + val error = authScreenState.error + LaunchedEffect(error) { + if (error != null) { + snackbarState.showSnackbar(error.name) + authScreenState.dismissError() + } + } + SnackbarHost(hostState = snackbarState, modifier) { + val stringId = error?.stringResId() + if (stringId != null) { + Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(stringId), Modifier.testTag(AuthScreenTag.LoginError)) + } + } + } +} + +@Composable +private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + Box(modifier) { + Button( + onClick = onClick, + Modifier + .fillMaxWidth() + .testTag(AuthScreenTag.LoginButton) + ) { + Text(text = "Login") + } + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier) { + Text( + stringResource(id = R.string.login_title), + modifier = modifier.padding(16.dp), + style = MaterialTheme.typography.h4 + ) +} + +private fun AuthScreenState.ErrorType.stringResId() = when (this) { + AuthScreenState.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid + AuthScreenState.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong + AuthScreenState.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid + AuthScreenState.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid +} + +object AuthScreenTag { + const val UsernameInput = "AuthScreenTag.UsernameInput" + const val PasswordInput = "AuthScreenTag.PasswordInput" + const val LoadingIndicator = "AuthScreenTag.LoadingIndicator" + const val LoginButton = "AuthScreenTag.LoginButton" + const val LoginError = "AuthScreenTag.LoginError" + const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle" +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt new file mode 100644 index 0000000..49454f6 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt @@ -0,0 +1,109 @@ +package org.fnives.test.showcase.hilt.compose.screen.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.login.LoginUseCase +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer + +@Composable +fun rememberAuthScreenState( + stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main }, + loginUseCase: LoginUseCase = AuthEntryPoint.get().loginUseCase, + onLoginSuccess: () -> Unit = {}, +): AuthScreenState { + return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) { + AuthScreenState(stateScope, loginUseCase, onLoginSuccess) + } +} + +class AuthScreenState( + private val stateScope: CoroutineScope, + private val loginUseCase: LoginUseCase, + private val onLoginSuccess: () -> Unit = {}, +) { + + var username by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + var loading by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + + fun onUsernameChanged(username: String) { + this.username = username + } + + fun onPasswordChanged(password: String) { + this.password = password + } + + fun onLogin() { + if (loading) { + return + } + loading = true + stateScope.launch { + val credentials = LoginCredentials( + username = username, + password = password + ) + when (val response = loginUseCase.invoke(credentials)) { + is Answer.Error -> error = ErrorType.GENERAL_NETWORK_ERROR + is Answer.Success -> processLoginStatus(response.data) + } + loading = false + } + } + + private fun processLoginStatus(loginStatus: LoginStatus) { + when (loginStatus) { + LoginStatus.SUCCESS -> onLoginSuccess() + LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS + LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME + LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD + } + } + + fun dismissError() { + error = null + } + + enum class ErrorType { + INVALID_CREDENTIALS, + GENERAL_NETWORK_ERROR, + UNSUPPORTED_USERNAME, + UNSUPPORTED_PASSWORD + } + + companion object { + private const val USERNAME = "USERNAME" + private const val PASSWORD = "PASSWORD" + + fun getSaver( + stateScope: CoroutineScope, + loginUseCase: LoginUseCase, + onLoginSuccess: () -> Unit, + ): Saver = mapSaver( + save = { mapOf(USERNAME to it.username, PASSWORD to it.password) }, + restore = { + AuthScreenState(stateScope, loginUseCase, onLoginSuccess).apply { + onUsernameChanged(it.getOrElse(USERNAME) { "" } as String) + onPasswordChanged(it.getOrElse(PASSWORD) { "" } as String) + } + } + ) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt new file mode 100644 index 0000000..c608e36 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.hilt.compose.screen.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase + +object HomeEntryPoint { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface MainDependencies { + val getAllContentUseCase: GetAllContentUseCase + val logoutUseCase: LogoutUseCase + val fetchContentUseCase: FetchContentUseCase + val addContentToFavouriteUseCase: AddContentToFavouriteUseCase + val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase + } + + @Composable + fun get(): MainDependencies { + val context = LocalContext.current.applicationContext + return remember { EntryPoints.get(context, MainDependencies::class.java) } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreen.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreen.kt new file mode 100644 index 0000000..6e61b19 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreen.kt @@ -0,0 +1,125 @@ +package org.fnives.test.showcase.hilt.compose.screen.home + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil.compose.rememberImagePainter +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.model.content.FavouriteContent + +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + homeScreenState: HomeScreenState = rememberHomeScreenState() +) { + Column(modifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Title(Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.logout_24), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.primary), + modifier = Modifier + .padding(16.dp) + .clickable { homeScreenState.onLogout() } + ) + } + Box { + if (homeScreenState.isError) { + ErrorText(Modifier.align(Alignment.Center)) + } + SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading), + onRefresh = { + homeScreenState.onRefresh() + } + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(homeScreenState.content) { item -> + Item( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + favouriteContent = item, + onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) } + ) + } + } + } + } + } +} + +@Composable +private fun Item( + modifier: Modifier = Modifier, + favouriteContent: FavouriteContent, + onFavouriteToggle: () -> Unit, +) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = rememberImagePainter(favouriteContent.content.imageUrl.url), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .height(120.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + ) + Column( + Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + Text(text = favouriteContent.content.title) + Text(text = favouriteContent.content.description) + } + val favouriteIcon = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24 + Image( + painter = painterResource(id = favouriteIcon), + contentDescription = null, + Modifier.clickable { onFavouriteToggle() } + ) + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier) { + Text( + stringResource(id = R.string.login_title), + modifier = modifier.padding(16.dp), + style = MaterialTheme.typography.h4 + ) +} + +@Composable +private fun ErrorText(modifier: Modifier = Modifier) { + Text( + stringResource(id = R.string.something_went_wrong), + modifier = modifier.padding(16.dp), + style = MaterialTheme.typography.h4, + textAlign = TextAlign.Center + ) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt new file mode 100644 index 0000000..a7e2643 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt @@ -0,0 +1,133 @@ +package org.fnives.test.showcase.hilt.compose.screen.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource + +@Composable +fun rememberHomeScreenState( + stateScope: CoroutineScope = rememberCoroutineScope(), + mainDependencies: HomeEntryPoint.MainDependencies = HomeEntryPoint.get(), + onLogout: () -> Unit = {}, +) = + rememberHomeScreenState( + stateScope = stateScope, + getAllContentUseCase = mainDependencies.getAllContentUseCase, + logoutUseCase = mainDependencies.logoutUseCase, + fetchContentUseCase = mainDependencies.fetchContentUseCase, + addContentToFavouriteUseCase = mainDependencies.addContentToFavouriteUseCase, + removeContentFromFavouritesUseCase = mainDependencies.removeContentFromFavouritesUseCase, + onLogout = onLogout, + ) + +@Suppress("LongParameterList") +@Composable +fun rememberHomeScreenState( + stateScope: CoroutineScope = rememberCoroutineScope(), + getAllContentUseCase: GetAllContentUseCase, + logoutUseCase: LogoutUseCase, + fetchContentUseCase: FetchContentUseCase, + addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase, + onLogout: () -> Unit = {}, +): HomeScreenState { + return remember { + HomeScreenState( + stateScope, + getAllContentUseCase, + logoutUseCase, + fetchContentUseCase, + addContentToFavouriteUseCase, + removeContentFromFavouritesUseCase, + onLogout, + ) + } +} + +@Suppress("LongParameterList") +class HomeScreenState( + private val stateScope: CoroutineScope, + private val getAllContentUseCase: GetAllContentUseCase, + private val logoutUseCase: LogoutUseCase, + private val fetchContentUseCase: FetchContentUseCase, + private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase, + private val logoutEvent: () -> Unit, +) { + + var loading by mutableStateOf(false) + private set + var isError by mutableStateOf(false) + private set + var content by mutableStateOf>(emptyList()) + private set + + init { + stateScope.launch { + fetch().collect { + content = it + } + } + } + + private fun fetch() = getAllContentUseCase.get() + .mapNotNull { + when (it) { + is Resource.Error -> { + isError = true + loading = false + return@mapNotNull emptyList() + } + is Resource.Loading -> { + isError = false + loading = true + return@mapNotNull null + } + is Resource.Success -> { + isError = false + loading = false + return@mapNotNull it.data + } + } + } + + fun onLogout() { + stateScope.launch { + logoutUseCase.invoke() + logoutEvent() + } + } + + fun onRefresh() { + if (loading) return + loading = true + stateScope.launch { + fetchContentUseCase.invoke() + } + } + + fun onFavouriteToggleClicked(contentId: ContentId) { + stateScope.launch { + val item = content.firstOrNull { it.content.id == contentId } ?: return@launch + if (item.isFavourite) { + removeContentFromFavouritesUseCase.invoke(contentId) + } else { + addContentToFavouriteUseCase.invoke(contentId) + } + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/splash/SplashScreen.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/splash/SplashScreen.kt new file mode 100644 index 0000000..19029d8 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/splash/SplashScreen.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.hilt.compose.screen.splash + +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.fnives.test.showcase.hilt.R + +@Composable +fun SplashScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(R.color.purple_700)), + contentAlignment = Alignment.Center + ) { + val resourceId = if (VERSION.SDK_INT >= VERSION_CODES.N) { + R.drawable.ic_launcher_foreground + } else { + R.mipmap.ic_launcher_round + } + Image( + painter = painterResource(resourceId), + contentDescription = null, + modifier = Modifier.size(120.dp) + ) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/AppModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/AppModule.kt new file mode 100644 index 0000000..31edc36 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/AppModule.kt @@ -0,0 +1,42 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.hilt.session.SessionExpirationListenerImpl +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteContentLocalStorageImpl +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object AppModule { + + @Provides + fun enableLogging(): Boolean = true + + @Singleton + @Provides + fun provideFavouriteDao(localDatabase: LocalDatabase) = + localDatabase.favouriteDao + + @Provides + fun provideSharedPreferencesManagerImpl(@ApplicationContext context: Context) = + SharedPreferencesManagerImpl.create(context) + + @Provides + fun provideFavouriteContentLocalStorage( + favouriteContentLocalStorageImpl: FavouriteContentLocalStorageImpl + ): FavouriteContentLocalStorage = favouriteContentLocalStorageImpl + + @Provides + internal fun bindSessionExpirationListener( + sessionExpirationListenerImpl: SessionExpirationListenerImpl + ): SessionExpirationListener = sessionExpirationListenerImpl +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/BaseUrlModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/BaseUrlModule.kt new file mode 100644 index 0000000..8bb6c42 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/BaseUrlModule.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.BuildConfig + +@InstallIn(SingletonComponent::class) +@Module + +object BaseUrlModule { + + @Provides + fun provideBaseUrl(): String = BuildConfig.BASE_URL +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/StorageModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/StorageModule.kt new file mode 100644 index 0000000..b0028a2 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/StorageModule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.database.DatabaseInitialization +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object StorageModule { + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + DatabaseInitialization.create(context) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/UserDataLocalStorageModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/UserDataLocalStorageModule.kt new file mode 100644 index 0000000..07dcd4c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/UserDataLocalStorageModule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object UserDataLocalStorageModule { + + @Singleton + @Provides + fun provideUserDataLocalStorage( + sharedPreferencesManagerImpl: SharedPreferencesManagerImpl + ): UserDataLocalStorage = sharedPreferencesManagerImpl +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/session/SessionExpirationListenerImpl.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/session/SessionExpirationListenerImpl.kt new file mode 100644 index 0000000..e53e33c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/session/SessionExpirationListenerImpl.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.session + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import dagger.hilt.android.qualifiers.ApplicationContext +import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import javax.inject.Inject + +class SessionExpirationListenerImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : SessionExpirationListener { + + override fun onSessionExpired() { + Handler(Looper.getMainLooper()).post { + context.startActivity( + IntentCoordinator.authActivitygetStartIntent(context) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/LocalDatabase.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/LocalDatabase.kt new file mode 100644 index 0000000..15f906c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/LocalDatabase.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.storage + +import androidx.room.Database +import androidx.room.RoomDatabase +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteDao +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity + +@Database( + entities = [FavouriteEntity::class], + version = 2, + exportSchema = true +) +abstract class LocalDatabase : RoomDatabase() { + + abstract val favouriteDao: FavouriteDao +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/SharedPreferencesManagerImpl.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/SharedPreferencesManagerImpl.kt new file mode 100644 index 0000000..9172135 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/SharedPreferencesManagerImpl.kt @@ -0,0 +1,68 @@ +package org.fnives.test.showcase.hilt.storage + +import android.content.Context +import android.content.SharedPreferences +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class SharedPreferencesManagerImpl( + private val sharedPreferences: SharedPreferences +) : UserDataLocalStorage { + + override var session: Session? by SessionDelegate(SESSION_KEY) + + private class SessionDelegate(private val key: String) : + ReadWriteProperty { + + override fun setValue( + thisRef: SharedPreferencesManagerImpl, + property: KProperty<*>, + value: Session? + ) { + if (value == null) { + thisRef.sharedPreferences.edit().remove(key).apply() + } else { + val values = setOf( + ACCESS_TOKEN_KEY + value.accessToken, + REFRESH_TOKEN_KEY + value.refreshToken + ) + thisRef.sharedPreferences.edit().putStringSet(key, values).apply() + } + } + + override fun getValue( + thisRef: SharedPreferencesManagerImpl, + property: KProperty<*> + ): Session? { + val values = thisRef.sharedPreferences.getStringSet(key, null)?.toList() + val accessToken = values?.firstOrNull { it.startsWith(ACCESS_TOKEN_KEY) } + ?.drop(ACCESS_TOKEN_KEY.length) ?: return null + val refreshToken = values.firstOrNull { it.startsWith(REFRESH_TOKEN_KEY) } + ?.drop(REFRESH_TOKEN_KEY.length) ?: return null + + return Session(accessToken = accessToken, refreshToken = refreshToken) + } + + companion object { + private const val ACCESS_TOKEN_KEY = "ACCESS_TOKEN_KEY" + private const val REFRESH_TOKEN_KEY = "REFRESH_TOKEN_KEY" + } + } + + companion object { + + private const val SESSION_KEY = "SESSION_KEY" + private const val SESSION_SHARED_PREFERENCES_NAME = "SESSION_SHARED_PREFERENCES_NAME" + + fun create(context: Context): SharedPreferencesManagerImpl { + val sharedPreferences = context.getSharedPreferences( + SESSION_SHARED_PREFERENCES_NAME, + Context.MODE_PRIVATE + ) + + return SharedPreferencesManagerImpl(sharedPreferences) + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/database/DatabaseInitialization.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/database/DatabaseInitialization.kt new file mode 100644 index 0000000..dd147d9 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/database/DatabaseInitialization.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.hilt.storage.database + +import android.content.Context +import androidx.room.Room +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.migation.Migration1To2 + +object DatabaseInitialization { + + fun create(context: Context): LocalDatabase = + Room.databaseBuilder(context, LocalDatabase::class.java, "local_database") + .addMigrations(Migration1To2()) + .allowMainThreadQueries() + .build() +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImpl.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImpl.kt new file mode 100644 index 0000000..78b3479 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImpl.kt @@ -0,0 +1,23 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import javax.inject.Inject + +class FavouriteContentLocalStorageImpl @Inject constructor( + private val favouriteDao: FavouriteDao +) : FavouriteContentLocalStorage { + + override fun observeFavourites(): Flow> = + favouriteDao.get().map { it.map(FavouriteEntity::contentId).map(::ContentId) } + + override suspend fun markAsFavourite(contentId: ContentId) { + favouriteDao.addFavourite(FavouriteEntity(contentId.id)) + } + + override suspend fun deleteAsFavourite(contentId: ContentId) { + favouriteDao.deleteFavourite(FavouriteEntity(contentId.id)) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteDao.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteDao.kt new file mode 100644 index 0000000..00d4a9e --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteDao.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface FavouriteDao { + + @Query("SELECT * FROM FavouriteEntity") + fun get(): Flow> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun addFavourite(favouriteEntity: FavouriteEntity) + + @Delete + suspend fun deleteFavourite(favouriteEntity: FavouriteEntity) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteEntity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteEntity.kt new file mode 100644 index 0000000..e957540 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteEntity.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class FavouriteEntity( + @ColumnInfo(name = "content_id") + @PrimaryKey val contentId: String +) diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/migation/Migration1To2.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/migation/Migration1To2.kt new file mode 100644 index 0000000..d459d87 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/migation/Migration1To2.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.hilt.storage.migation + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration1To2 : Migration(1, 2) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE FavouriteEntity RENAME TO FavouriteEntityOld") + database.execSQL("CREATE TABLE FavouriteEntity(content_id TEXT NOT NULL PRIMARY KEY)") + database.execSQL("INSERT INTO FavouriteEntity(content_id) SELECT contentId FROM FavouriteEntityOld") + database.execSQL("DROP TABLE FavouriteEntityOld") + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/IntentCoordinator.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/IntentCoordinator.kt new file mode 100644 index 0000000..4960264 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/IntentCoordinator.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.hilt.ui + +import android.content.Context +import android.content.Intent +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.hilt.ui.home.MainActivity + +object IntentCoordinator { + + fun mainActivitygetStartIntent(context: Context): Intent = + MainActivity.getStartIntent(context) + + fun authActivitygetStartIntent(context: Context): Intent = + AuthActivity.getStartIntent(context) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/ViewModelDelegate.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/ViewModelDelegate.kt new file mode 100644 index 0000000..f6ec4fd --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/ViewModelDelegate.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.ui + +import androidx.activity.ComponentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.activity.viewModels as androidxViewModel + +inline fun ViewModelStoreOwner.viewModels(): Lazy = + when (this) { + is ComponentActivity -> androidxViewModel() + else -> throw IllegalStateException("Only supports activity viewModel for now") + } diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivity.kt new file mode 100644 index 0000000..998bc1c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivity.kt @@ -0,0 +1,57 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.databinding.ActivityAuthenticationBinding +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import org.fnives.test.showcase.hilt.ui.viewModels + +@AndroidEntryPoint +class AuthActivity : AppCompatActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityAuthenticationBinding.inflate(layoutInflater) + viewModel.loading.observe(this) { + binding.loadingIndicator.isVisible = it == true + } + viewModel.password.observe(this, SetTextIfNotSameObserver(binding.passwordEditText)) + binding.passwordEditText.doAfterTextChanged { viewModel.onPasswordChanged(it?.toString().orEmpty()) } + viewModel.username.observe(this, SetTextIfNotSameObserver(binding.userEditText)) + binding.userEditText.doAfterTextChanged { viewModel.onUsernameChanged(it?.toString().orEmpty()) } + binding.loginCta.setOnClickListener { + viewModel.onLogin() + } + viewModel.error.observe(this) { + val stringResId = it?.consume()?.stringResId() ?: return@observe + Snackbar.make(binding.snackbarHolder, stringResId, Snackbar.LENGTH_LONG).show() + } + viewModel.navigateToHome.observe(this) { + it.consume() ?: return@observe + startActivity(IntentCoordinator.mainActivitygetStartIntent(this)) + finishAffinity() + } + setContentView(binding.root) + } + + companion object { + + private fun AuthViewModel.ErrorType.stringResId() = when (this) { + AuthViewModel.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid + AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong + AuthViewModel.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid + AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid + } + + fun getStartIntent(context: Context): Intent = Intent(context, AuthActivity::class.java) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModel.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..fdf1e66 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModel.kt @@ -0,0 +1,69 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.login.LoginUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor(private val loginUseCase: LoginUseCase) : ViewModel() { + + private val _username = MutableLiveData() + val username: LiveData = _username + private val _password = MutableLiveData() + val password: LiveData = _password + private val _loading = MutableLiveData(false) + val loading: LiveData = _loading + private val _error = MutableLiveData>() + val error: LiveData> = _error + private val _navigateToHome = MutableLiveData>() + val navigateToHome: LiveData> = _navigateToHome + + fun onPasswordChanged(password: String) { + _password.value = password + } + + fun onUsernameChanged(username: String) { + _username.value = username + } + + fun onLogin() { + if (_loading.value == true) return + _loading.value = true + viewModelScope.launch { + val credentials = LoginCredentials( + username = _username.value.orEmpty(), + password = _password.value.orEmpty() + ) + when (val response = loginUseCase.invoke(credentials)) { + is Answer.Error -> _error.value = Event(ErrorType.GENERAL_NETWORK_ERROR) + is Answer.Success -> processLoginStatus(response.data) + } + _loading.postValue(false) + } + } + + private fun processLoginStatus(loginStatus: LoginStatus) { + when (loginStatus) { + LoginStatus.SUCCESS -> _navigateToHome.value = Event(Unit) + LoginStatus.INVALID_CREDENTIALS -> _error.value = Event(ErrorType.INVALID_CREDENTIALS) + LoginStatus.INVALID_USERNAME -> _error.value = Event(ErrorType.UNSUPPORTED_USERNAME) + LoginStatus.INVALID_PASSWORD -> _error.value = Event(ErrorType.UNSUPPORTED_PASSWORD) + } + } + + enum class ErrorType { + INVALID_CREDENTIALS, + GENERAL_NETWORK_ERROR, + UNSUPPORTED_USERNAME, + UNSUPPORTED_PASSWORD + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/SetTextIfNotSameObserver.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/SetTextIfNotSameObserver.kt new file mode 100644 index 0000000..1c450f6 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/SetTextIfNotSameObserver.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import android.widget.EditText +import androidx.lifecycle.Observer + +class SetTextIfNotSameObserver(private val editText: EditText) : Observer { + override fun onChanged(t: String?) { + val current = editText.text?.toString() + if (current != t) { + editText.setText(t) + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/FavouriteContentAdapter.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/FavouriteContentAdapter.kt new file mode 100644 index 0000000..939feac --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/FavouriteContentAdapter.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.hilt.ui.home + +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.databinding.ItemFavouriteContentBinding +import org.fnives.test.showcase.hilt.ui.shared.ViewBindingAdapter +import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor +import org.fnives.test.showcase.hilt.ui.shared.layoutInflater +import org.fnives.test.showcase.hilt.ui.shared.loadRoundedImage +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent + +class FavouriteContentAdapter( + private val listener: OnFavouriteItemClicked, +) : ListAdapter>( + AsyncDifferConfig.Builder(DiffUtilItemCallback()) + .setBackgroundThreadExecutor(AsyncTaskExecutor.iOThreadExecutor) + .build() +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewBindingAdapter = + ViewBindingAdapter(ItemFavouriteContentBinding.inflate(parent.layoutInflater(), parent, false)).apply { + viewBinding.favouriteCta.setOnClickListener { + if (adapterPosition in 0 until itemCount) { + listener.onFavouriteToggleClicked(getItem(adapterPosition).content.id) + } + } + } + + override fun onBindViewHolder(holder: ViewBindingAdapter, position: Int) { + val item = getItem(position) + holder.viewBinding.img.loadRoundedImage(item.content.imageUrl) + holder.viewBinding.title.text = item.content.title + holder.viewBinding.description.text = item.content.description + val favouriteResId = if (item.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24 + holder.viewBinding.favouriteCta.setImageResource(favouriteResId) + } + + interface OnFavouriteItemClicked { + fun onFavouriteToggleClicked(contentId: ContentId) + } + + class DiffUtilItemCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean = + oldItem.content.id == newItem.content.id + + override fun areContentsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean = + oldItem == newItem + + override fun getChangePayload(oldItem: FavouriteContent, newItem: FavouriteContent): Any? = oldItem + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainActivity.kt new file mode 100644 index 0000000..0e442b4 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainActivity.kt @@ -0,0 +1,72 @@ +package org.fnives.test.showcase.hilt.ui.home + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.databinding.ActivityMainBinding +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import org.fnives.test.showcase.hilt.ui.shared.VerticalSpaceItemDecoration +import org.fnives.test.showcase.hilt.ui.shared.getThemePrimaryColor +import org.fnives.test.showcase.hilt.ui.viewModels +import org.fnives.test.showcase.model.content.ContentId + +@AndroidEntryPoint +open class MainActivity : AppCompatActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityMainBinding.inflate(layoutInflater) + binding.toolbar.menu?.findItem(R.id.logout_cta)?.setOnMenuItemClickListener { + viewModel.onLogout() + true + } + binding.swipeRefreshLayout.setColorSchemeColors(binding.swipeRefreshLayout.getThemePrimaryColor()) + binding.swipeRefreshLayout.setOnRefreshListener { + viewModel.onRefresh() + } + + val adapter = FavouriteContentAdapter(viewModel.mapToAdapterListener()) + binding.recycler.layoutManager = LinearLayoutManager(this) + binding.recycler.addItemDecoration( + VerticalSpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.padding)) + ) + binding.recycler.adapter = adapter + + viewModel.content.observe(this) { + adapter.submitList(it.orEmpty()) + } + viewModel.errorMessage.observe(this) { + binding.errorMessage.isVisible = it == true + } + viewModel.navigateToAuth.observe(this) { + it.consume() ?: return@observe + startActivity(IntentCoordinator.authActivitygetStartIntent(this)) + finishAffinity() + } + viewModel.loading.observe(this) { + if (binding.swipeRefreshLayout.isRefreshing != it) { + binding.swipeRefreshLayout.isRefreshing = it == true + } + } + + setContentView(binding.root) + } + + companion object { + fun getStartIntent(context: Context): Intent = Intent(context, MainActivity::class.java) + + private fun MainViewModel.mapToAdapterListener(): FavouriteContentAdapter.OnFavouriteItemClicked = + object : FavouriteContentAdapter.OnFavouriteItemClicked { + override fun onFavouriteToggleClicked(contentId: ContentId) { + this@mapToAdapterListener.onFavouriteToggleClicked(contentId) + } + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainViewModel.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainViewModel.kt new file mode 100644 index 0000000..030679c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainViewModel.kt @@ -0,0 +1,84 @@ +package org.fnives.test.showcase.hilt.ui.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val getAllContentUseCase: GetAllContentUseCase, + private val logoutUseCase: LogoutUseCase, + private val fetchContentUseCase: FetchContentUseCase, + private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase +) : ViewModel() { + + private val _loading = MutableLiveData() + val loading: LiveData = _loading + private val _content: LiveData> = liveData { + getAllContentUseCase.get().collect { + when (it) { + is Resource.Error -> { + _errorMessage.value = true + _loading.value = false + emit(emptyList()) + } + is Resource.Loading -> { + _errorMessage.value = false + _loading.value = true + } + is Resource.Success -> { + _errorMessage.value = false + _loading.value = false + emit(it.data) + } + } + } + } + val content: LiveData> = _content + private val _errorMessage = MutableLiveData(false) + val errorMessage: LiveData = _errorMessage.distinctUntilChanged() + private val _navigateToAuth = MutableLiveData>() + val navigateToAuth: LiveData> = _navigateToAuth + + fun onLogout() { + viewModelScope.launch { + logoutUseCase.invoke() + _navigateToAuth.value = Event(Unit) + } + } + + fun onRefresh() { + if (_loading.value == true) return + _loading.value = true + viewModelScope.launch { + fetchContentUseCase.invoke() + } + } + + fun onFavouriteToggleClicked(contentId: ContentId) { + viewModelScope.launch { + val content = _content.value?.firstOrNull { it.content.id == contentId } ?: return@launch + if (content.isFavourite) { + removeContentFromFavouritesUseCase.invoke(contentId) + } else { + addContentToFavouriteUseCase.invoke(contentId) + } + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/Event.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/Event.kt new file mode 100644 index 0000000..7cce3a9 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/Event.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.ui.shared + +@Suppress("DataClassContainsFunctions") +data class Event(private val data: T) { + + private var consumed: Boolean = false + + fun consume(): T? = data.takeUnless { consumed }?.also { consumed = true } + + fun peek() = data +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/VerticalSpaceItemDecoration.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/VerticalSpaceItemDecoration.kt new file mode 100644 index 0000000..b8fadc7 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/VerticalSpaceItemDecoration.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + outRect.set(0, 0, 0, verticalSpaceHeight) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewBindingAdapter.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewBindingAdapter.kt new file mode 100644 index 0000000..cd1c1a5 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewBindingAdapter.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +class ViewBindingAdapter(val viewBinding: T) : RecyclerView.ViewHolder(viewBinding.root) diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewExtension.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewExtension.kt new file mode 100644 index 0000000..26fe1ec --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewExtension.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import coil.load +import coil.transform.RoundedCornersTransformation +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.model.content.ImageUrl + +fun View.layoutInflater(): LayoutInflater = LayoutInflater.from(context) + +fun ImageView.loadRoundedImage(imageUrl: ImageUrl) { + load(imageUrl.url) { + transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.rounded_corner))) + } +} + +fun View.getThemePrimaryColor(): Int { + val value = TypedValue() + context.theme.resolveAttribute(R.attr.colorPrimary, value, true) + return value.data +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/AsyncTaskExecutor.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/AsyncTaskExecutor.kt new file mode 100644 index 0000000..c6c4773 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/AsyncTaskExecutor.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.hilt.ui.shared.executor + +import java.util.concurrent.Executor + +/** + * Basic copy of [ArchTaskExecutor][androidx.arch.core.executor.ArchTaskExecutor], needed because that is restricted to Library. + * + * Intended to be used for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig] so it can be synchronized with Espresso. + * + * Workaround until https://github.com/android/android-test/issues/382 is fixed finally. + */ +object AsyncTaskExecutor : TaskExecutor { + + val mainThreadExecutor = Executor { command -> postToMainThread(command) } + val iOThreadExecutor = Executor { command -> executeOnDiskIO(command) } + + var delegate: TaskExecutor? = null + private val defaultExecutor by lazy { DefaultTaskExecutor() } + private val executor get() = delegate ?: defaultExecutor + + override fun executeOnDiskIO(runnable: Runnable) { + executor.executeOnDiskIO(runnable) + } + + override fun postToMainThread(runnable: Runnable) { + executor.postToMainThread(runnable) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/DefaultTaskExecutor.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/DefaultTaskExecutor.kt new file mode 100644 index 0000000..9f2fb92 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/DefaultTaskExecutor.kt @@ -0,0 +1,34 @@ +package org.fnives.test.showcase.hilt.ui.shared.executor + +import android.os.Build +import android.os.Handler +import android.os.Looper +import java.util.concurrent.Executors + +/** + * Basic copy of [androidx.arch.core.executor.DefaultTaskExecutor], needed because that is restricted to Library. + * With a Flavour of [androidx.recyclerview.widget.AsyncDifferConfig]. + * Used within [AsyncTaskExecutor]. + * + * Intended to be used for AsyncDiffUtil so it can be synchronized with Espresso. + */ +class DefaultTaskExecutor : TaskExecutor { + + private val diskIO = Executors.newFixedThreadPool(2) + private val mMainHandler: Handler by lazy { createAsync(Looper.getMainLooper()) } + + override fun executeOnDiskIO(runnable: Runnable) { + diskIO.execute(runnable) + } + + override fun postToMainThread(runnable: Runnable) { + mMainHandler.post(runnable) + } + + private fun createAsync(looper: Looper): Handler = + if (Build.VERSION.SDK_INT >= 28) { + Handler.createAsync(looper) + } else { + Handler(looper) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/TaskExecutor.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/TaskExecutor.kt new file mode 100644 index 0000000..cfd3d1d --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/TaskExecutor.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.shared.executor + +/** + * Define TaskExecutor intended for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig] + */ +interface TaskExecutor { + fun executeOnDiskIO(runnable: Runnable) + + fun postToMainThread(runnable: Runnable) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivity.kt new file mode 100644 index 0000000..fb60bf6 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivity.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import org.fnives.test.showcase.hilt.ui.viewModels + +@SuppressLint("CustomSplashScreen") +@AndroidEntryPoint +open class SplashActivity : AppCompatActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_splash) + viewModel.navigateTo.observe(this) { + val intent = when (it.consume()) { + SplashViewModel.NavigateTo.HOME -> IntentCoordinator.mainActivitygetStartIntent(this) + SplashViewModel.NavigateTo.AUTHENTICATION -> IntentCoordinator.authActivitygetStartIntent(this) + null -> return@observe + } + startActivity(intent) + finishAffinity() + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModel.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModel.kt new file mode 100644 index 0000000..da6ad8c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModel.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor(isUserLoggedInUseCase: IsUserLoggedInUseCase) : ViewModel() { + + private val _navigateTo = MutableLiveData>() + val navigateTo: LiveData> = _navigateTo + + init { + viewModelScope.launch { + delay(500L) + val navigationEvent = if (isUserLoggedInUseCase.invoke()) NavigateTo.HOME else NavigateTo.AUTHENTICATION + _navigateTo.value = Event(navigationEvent) + } + } + + enum class NavigateTo { + HOME, AUTHENTICATION + } +} diff --git a/hilt/hilt-app/src/main/res/drawable-v24/ic_compose_launcher_foreground.xml b/hilt/hilt-app/src/main/res/drawable-v24/ic_compose_launcher_foreground.xml new file mode 100644 index 0000000..327652c --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable-v24/ic_compose_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/hilt/hilt-app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..da1dcd9 --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/drawable/favorite_24.xml b/hilt/hilt-app/src/main/res/drawable/favorite_24.xml new file mode 100644 index 0000000..209e42e --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/favorite_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml b/hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml new file mode 100644 index 0000000..83e57ce --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/hilt/hilt-app/src/main/res/drawable/ic_launcher_background.xml b/hilt/hilt-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b219d51 --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/hilt/hilt-app/src/main/res/drawable/logout_24.xml b/hilt/hilt-app/src/main/res/drawable/logout_24.xml new file mode 100644 index 0000000..77928df --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/logout_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/hilt/hilt-app/src/main/res/drawable/show_password.xml b/hilt/hilt-app/src/main/res/drawable/show_password.xml new file mode 100644 index 0000000..6d35533 --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/show_password.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hilt/hilt-app/src/main/res/layout/activity_authentication.xml b/hilt/hilt-app/src/main/res/layout/activity_authentication.xml new file mode 100644 index 0000000..f012327 --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/activity_authentication.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/layout/activity_main.xml b/hilt/hilt-app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..908365c --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/activity_main.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/layout/activity_splash.xml b/hilt/hilt-app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..c758e5a --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/layout/item_favourite_content.xml b/hilt/hilt-app/src/main/res/layout/item_favourite_content.xml new file mode 100644 index 0000000..c4115ca --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/item_favourite_content.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/menu/main.xml b/hilt/hilt-app/src/main/res/menu/main.xml new file mode 100644 index 0000000..f42dec2 --- /dev/null +++ b/hilt/hilt-app/src/main/res/menu/main.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher.xml new file mode 100644 index 0000000..bf2bcc9 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher_round.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher_round.xml new file mode 100644 index 0000000..bf2bcc9 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d2ba5af013ea1272df32887a8dab49f8a66ca250 GIT binary patch literal 3232 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 Q9{>OV07*qoM6N<$f@xz_^8f$< literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..1d4a0227aa2c4418eeea916fe4c47a0eb242fe8e GIT binary patch literal 1739 zcmV;+1~mDJP)JULgxD#+;o}TC3JvdKExv$;Z*?0J{%iZ3)`~3EK z_kDNSJ;t=66|HDREB;;};ALhGQWF%DhvaMwRPT;=JGTA`rZUrfWK%4w+J)1dG0c?U zPtZ0?+rEin@qi@pgL-BP!3xb&StY0pv!3OLxG1T=ea5iLAH)hP1a(mfdUmiDLkx6| z`MyOwh>@Jq*ZSqCE`x+?BC3XEb!De4te zfr82*g6++qSX~hmq+_}J)SW@)5XQdAq*z@Pl$WZ0LCFR;1m&iwUC?B?Tt=`~NX0ZulWEms#L= zg=N4jqSs>sF*AmkI5a=?f0jLWyoJ|Eb*tha{Z6z2Cdf;; z1eY(g!RK>NL0M`&G&kCyyGwu`p$B?s>C3afa%mI^F2Olp$zw zXbqe^e3fU8r{vh7I%`c$d>wu%XlaBQem{G|<6@klbu0<1@h?Gdj6KS`VsFc_^K;EL zzD~aoG;>%L94&10nH)Q`PFlC(1?~9cJnz1p188mT0AqR`G&SCIS;F6cwLxk61+2?@ z*L$>1TDPJFExq&W#ou%y}*Y!q{$DKC2XL z*6zEOxn{A!{HMQDuAsb~m+skPwYPP`g7Ez)Xe-PblML6dSqCks^{N$SJdsNO$AZPt zg*ck-x@XUp8UHIpvl8VAnl+*tt~YlM*d?DfH^91;)nKl=2*T~2K?|Z$b!~M$Y*={= z>VEoXz@Y<)d}&11gGlnNpbg7@@yd9|mGRNqWi>E`rQc7I?*!3#(f0+7vdghkmGcMa zha5mjnGnN_@(ZHN4weR+Vgy+l+97th0V3G}&@sb6l6)uV^=FPMMvk3VN9FToHic!N zq%4$FDxaVa<7#=gt0Y#$9pZ9CGEq{tvn1aMq8pKDd_vdGf^wjtY*cupw$8rdPS z6E`eDVi%H#Bw-y%ydLVxiEy?DNv6BNB=$lCOOe=zq;euz8nT}^DZ-vPb~#h9nNd^I zvmCyyvh-roy*7%KL(Pn`NY8TZl6*f<6mMxu?h~UlIXcWBLeKJPhstvP#3{?=4pCByV&#Yw2$V^<9r9c-BS{flk`m?Q^9-VS+=234a;0Mu1*JSW z1(KkAc?I1mDM>+ch$0kJfPxa_|F@5LKXRIaa zBnnb~IR_z{3o1fEiORZxiX>5>^2#Af1e#ZzAp3!v0uApI<7+v^h*7zM`XyybVtg&9 zL=sf&?m#ietIukPk_7) zqvvQ3lO$h&0ShPzF(6rjj+2e#Ah{e`2Bk<8(11^(hn#E9?bQ7jab7~7bK-TCST zOuNy;arzW84hcmj`#GUfEFHq7}a={{fb5uKn8Ypx6Ka002ovPDHLkV1h8GLGu6r literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..d883c08144ada1b47f483987b2cb6228b2b4de5f GIT binary patch literal 3463 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 literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..64d18bf1e32e19b5d3f0c3313f9bd1fc03f728e0 GIT binary patch literal 2349 zcmV+|3DWk7P)}Bq1>%$fhDfROOkE*s;|f$D)qlD7Z^U!6E}F zg36*8h~rpKr)U+m+Pc-PBkEK~>!>WTwTSYaXYTz%AYT$lSgiih_naR|zU@BGd*AnR z355I>FLAs}3T*;NS*I2WDC?A1mM9<~G12(CHo`O*$g(67t+l)?(JDPn7s^SoNJ`X> zGNN+OXLui#!;I_=4soYenq^#Ewu7v@@P12SV>1n=v^5jj3+RnK1&$SdO$^Sc*X4R|#U9(8ZF0qW1-e)(_X{@8} zIlsOI+|<+*F81q{T--M++Dz<;mE7c{&75zaLXJM$ENpD1fobs~g%1Xjq}TPe3W9Y! zdmTd32hf|Uc>~reE+jA)GmF=P*0WJo+XQ{ijKXzb&sA6(XW9^Eq`^Rq6Nz`#*D46z zK^KzjJd~7F`SegBB07U(Q*R`q1BfYGg?B#KiebMmHWJ;uf%$IwS_PqR!1uL1)sxq4 z0$(yF1~(RfSt({qD)z)`gfBP>Mc4i9Gaz>HaGp{U zZ_)sp=1HqIn_W2!VY3l2Cj)biCL0OYF){NCghX9zzky!;J)E7!hE+#@l_)R}QtHCztaErl{lLGitfXdLP&C3mh)Fi!pr;8J#o4$L@0|7oSDs-rZPwHVqf6 zE3l|M2jh}1A$-}5jx}KC>5tJHVz{vxf5Mw1L*Qh;&`6MuPZxZLEsHP1+gc5URVhY^ z4hdMVEciFZueb))nVTs1z8rr{J;saga04QDKfEpr zd{kdF1e4fi7&ta%Y1V5S^V<&sgbbOP+M1zRBGZ^HxM)Y7@pF> zbwZv!t4Do()903`uh*jc{PY=zMOk-$Jp+NS9Kfw>KQ#*Y>CqD;%szmuwa0lQhJp>n z7apnN8~Jf<)3tXV)*)i<<>zmJl}=jxC4s106;%l8n~G@xJMl9$V<=i5CVElU9n9Kz z3-_w(1m`m83a03$e!d1q*!~k07d3)`S?S!k`Vc?f79MYo`!!E-`@s{`8yvrOO#^>X zKIEinL)YO3W-7iyy_qs;E3CZUm#ZKlI~q-NxPgryT`(HBBUv-=I6O%fq$HP`P<6b4 zO$*QA+hezIhUc~c|25!_fb-uzz@?jyap{L&OmdMf4C@!1qz1An+o*}1RCsa67;xw} z6<(e4X-4*75cw3YB$={`vIcHsEoB{LJw3O9vXPR)U*}H#M%e_ZeGY=({~F;-o3;*y zjGA>0lCC+tcu%sOmJ)lvlVdZ{ZUZ9s;ppoS1uvTd-h?}E!o2vwHmUIT*oV-ME*l9p zL~9_oQ9SE~AtZNhvDt+9P4IHeLrCleBatmJYUVj=AdeT$kdizq zJeL~S#rndW0qsHa2DCWzi)=J8n4~p|AKWDke*Om$wxq&HU`sIZa=Rjdcx~qMXbJ6R zeL)S(F>j!MuV`oSAb+z47;GkV<>W!qA$@HdMns;rxMUb%i!0zCd<4?Y!pt-AEb|iD z#Hz187B#9qA#oSsGe8ZAwYJ_3#k0&y$S!K2 zke#!vgqQ&t*;nn-dpX1dgUlH4Lm&Bg_)bBhk9-zZDdVzz^vvv&{0637vlhezHT5bP=vLro^+%OL+r( z*i2aV<*qwf?;>mXdfm&an0s5406SM>6Jn151kW!w64)%XFGfb5!O%DM!qcj0oo`7z z%e<7Z|0V3D*X^$9Pv*PpYZZjcZdl(N{(~|9J?>q-0UDHJ&`%C^;Gl&khY+HtG26eHGoVl=dVptbAG?uM2jeTNnDKC*_qFOi!&Ai^VquYZfWl+Sy{HCXTg}78>@qcq!ul&+|>e Tk=ihZ00000NkvXXu0mjfSvqaf literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..90bfe74dc5c4fe7507e29dccebf298c6cd48310b GIT binary patch literal 3179 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 il<(oymer;6asEGfAq8aLtL9h$00002 literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..df2fc2fa51897664a66d0b887cd700a84e7e1111 GIT binary patch literal 4489 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 buSWSl`vo#@Ll26Z00000NkvXXu0mjfJ&b8C literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..70cfdc1d920caffc92cf9bf71ac2169142eb5f9f GIT binary patch literal 7126 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%0A1%!dYq* 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 RPMiP$002ovPDHLkV1fpd82tbM literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..c0e068289b281cdeee442aecfa5941d1f4a884a8 GIT binary patch literal 3620 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 H_qq9B{?ncQ literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..969e3a65fd7655bafe5325096b399229916c213b GIT binary patch literal 7728 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 literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..df16be981387fbff53cb63010362fb3314c2013f GIT binary patch literal 15485 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 literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..ce478dc44f26ff85ef36fdd0d574b1f678a6cff6 GIT binary patch literal 5121 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 literal 0 HcmV?d00001 diff --git a/hilt/hilt-app/src/main/res/values-night/themes.xml b/hilt/hilt-app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4e197d3 --- /dev/null +++ b/hilt/hilt-app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/colors.xml b/hilt/hilt-app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/dimens.xml b/hilt/hilt-app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..67abbea --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/dimens.xml @@ -0,0 +1,11 @@ + + + 24dp + 56dp + 16dp + 8dp + 120dp + 6dp + 48dp + 12dp + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/strings.xml b/hilt/hilt-app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1192bd3 --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + Hilt Test ShowCase + Hilt Compose Test ShowCase + Login + Username + Password + Username is not filled properly! + Password is not filled properly! + No User with given credentials! + Something went wrong! + Mock Login + Content + Logout + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/themes.xml b/hilt/hilt-app/src/main/res/values/themes.xml new file mode 100644 index 0000000..64b01cd --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt new file mode 100644 index 0000000..d90bfb7 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BindsBaseOkHttpClient::class] +) +object HttpsConfigurationModule { + + @Provides + @Singleton + @SessionLessQualifier + fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + HttpsConfigurationModuleTemplate.bindsBaseOkHttpClient(enableLogging, platformInterceptor) +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt new file mode 100644 index 0000000..cde8d0e --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BaseUrlModule::class] +) +object TestBaseUrlModule { + + @Provides + fun provideBaseUrl(): String = TestBaseUrlHolder.url +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt new file mode 100644 index 0000000..687707e --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [StorageModule::class] +) +object TestDatabaseInitializationModule { + + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + TestDatabaseInitialization.provideLocalDatabase(context) +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/UserDataLocalStorageTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/UserDataLocalStorageTest.kt new file mode 100644 index 0000000..ab38f20 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/UserDataLocalStorageTest.kt @@ -0,0 +1,73 @@ +package org.fnives.test.showcase.hilt.storage + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.test.KoinTest +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(ParameterizedRobolectricTestRunner::class) +class UserDataLocalStorageTest( + private val userDataLocalStorageFactory: () -> UserDataLocalStorage +) : KoinTest { + + private lateinit var userDataLocalStorage: UserDataLocalStorage + + @Before + fun setup() { + userDataLocalStorage = userDataLocalStorageFactory.invoke() + } + + @After + fun tearDown() { + stopKoin() + } + + /** GIVEN session value WHEN accessed THEN it's returned **/ + @Test + fun sessionSetWillStayBeKept() { + val session = Session(accessToken = "a", refreshToken = "b") + userDataLocalStorage.session = session + + val actual = userDataLocalStorage.session + + Assert.assertEquals(session, actual) + } + + /** GIVEN null value WHEN accessed THEN it's null **/ + @Test + fun sessionSetToNullWillStayNull() { + userDataLocalStorage.session = Session(accessToken = "a", refreshToken = "b") + + userDataLocalStorage.session = null + val actual = userDataLocalStorage.session + + Assert.assertEquals(null, actual) + } + + companion object { + + private fun createFake(): UserDataLocalStorage = FakeUserDataLocalStorage() + + private fun createReal(): UserDataLocalStorage { + val context = ApplicationProvider.getApplicationContext() + + return SharedPreferencesManagerImpl.create(context) + } + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun userDataLocalStorageFactories(): List<() -> UserDataLocalStorage> = listOf( + ::createFake, + ::createReal + ) + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt new file mode 100644 index 0000000..b348cd9 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt @@ -0,0 +1,147 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.integration.fake.FakeFavouriteContentLocalStorage +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.fnives.test.showcase.model.content.ContentId +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.stopKoin +import org.robolectric.ParameterizedRobolectricTestRunner +import javax.inject.Inject + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(ParameterizedRobolectricTestRunner::class) +internal class FavouriteContentLocalStorageImplInstrumentedTest( + private val favouriteContentLocalStorageFactory: (FavouriteContentLocalStorage) -> FavouriteContentLocalStorage, +) { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + private lateinit var sut: FavouriteContentLocalStorage + private lateinit var testDispatcher: TestDispatcher + + @Inject + lateinit var real: FavouriteContentLocalStorage + + @Before + fun setUp() { + testDispatcher = StandardTestDispatcher() + TestDatabaseInitialization.dispatcher = testDispatcher + hiltRule.inject() + sut = favouriteContentLocalStorageFactory(real) + } + + @After + fun tearDown() { + stopKoin() + } + + /** GIVEN just created database WHEN querying THEN empty list is returned */ + @Test + fun atTheStartOurDatabaseIsEmpty() = runTest(testDispatcher) { + val actual = sut.observeFavourites().first() + + Assert.assertEquals(emptyList(), actual) + } + + /** GIVEN content_id WHEN added to Favourite THEN it can be read out */ + @Test + fun addingContentIdToFavouriteCanBeLaterReadOut() = runTest(testDispatcher) { + val expected = listOf(ContentId("a")) + + sut.markAsFavourite(ContentId("a")) + val actual = sut.observeFavourites().first() + + Assert.assertEquals(expected, actual) + } + + /** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */ + @Test + fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = runTest(testDispatcher) { + val expected = listOf() + sut.markAsFavourite(ContentId("b")) + + sut.deleteAsFavourite(ContentId("b")) + val actual = sut.observeFavourites().first() + + Assert.assertEquals(expected, actual) + } + + /** GIVEN empty database WHILE observing content WHEN favourite added THEN change is emitted */ + @Test + fun addingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { + val expected = listOf(listOf(), listOf(ContentId("a"))) + val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() } + advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } + + /** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */ + @Test + fun removingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { + val expected = listOf(listOf(ContentId("a")), listOf()) + sut.markAsFavourite(ContentId("a")) + + val actual = async(coroutineContext) { + sut.observeFavourites().take(2).toList() + } + advanceUntilIdle() + + sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } + + /** GIVEN an observed WHEN adding and removing from it THEN we only get the expected amount of updates */ + @Test + fun noUnexpectedUpdates() = runTest(testDispatcher) { + val actual = async(coroutineContext) { sut.observeFavourites().take(4).toList() } + advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + advanceUntilIdle() + sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertFalse(actual.isCompleted) + actual.cancel() + } + + companion object { + + private fun createFake(): FavouriteContentLocalStorage = FakeFavouriteContentLocalStorage() + + private fun createReal(real: FavouriteContentLocalStorage): FavouriteContentLocalStorage = real + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun favouriteContentLocalStorageFactories(): List<(FavouriteContentLocalStorage) -> FavouriteContentLocalStorage> = listOf( + { createFake() }, + { createReal(it) } + ) + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt new file mode 100644 index 0000000..45ce6d9 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.storage.migration + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.hilt.test.shared.storage.migration.MigrationToLatestInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MigrationToLatestInstrumentedTest : MigrationToLatestInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricAuthActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricAuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..1463aa9 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricAuthActivityInstrumentedTest.kt @@ -0,0 +1,173 @@ +package org.fnives.test.showcase.hilt.ui + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.test.showcase.android.testutil.activity.safeClose +import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule.Companion.advanceUntilIdleWithIdlingResources +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class RobolectricAuthActivityInstrumentedTest { + + private lateinit var activityScenario: ActivityScenario + private lateinit var robot: RobolectricLoginRobot + private lateinit var testDispatcher: TestDispatcher + private lateinit var mockServerScenarioSetup: MockServerScenarioSetup + private lateinit var disposable: Disposable + + @Inject + lateinit var networkSynchronization: NetworkSynchronization + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun setup() { + Intents.init() + val dispatcher = StandardTestDispatcher() + Dispatchers.setMain(dispatcher) + testDispatcher = dispatcher + TestDatabaseInitialization.dispatcher = dispatcher + + mockServerScenarioSetup = MockServerScenarioSetup() + TestBaseUrlHolder.url = mockServerScenarioSetup.start(false) + + hiltRule.inject() + val idlingResources = networkSynchronization.networkIdlingResources() + .map(::IdlingResourceDisposable) + disposable = CompositeDisposable(idlingResources) + + robot = RobolectricLoginRobot() + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + mockServerScenarioSetup.stop() + disposable.dispose() + activityScenario.safeClose() + Intents.release() + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan"), + validateArguments = true + ) + + robot.setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertNavigatedToHome() + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + robot.setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan"), + validateArguments = true + ) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan"), + validateArguments = true + ) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricLoginRobot.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricLoginRobot.kt new file mode 100644 index 0000000..ad9b55a --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricLoginRobot.kt @@ -0,0 +1,71 @@ +package org.fnives.test.showcase.hilt.ui + +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsNotShown +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsShownWithText +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.hamcrest.core.IsNot.not + +class RobolectricLoginRobot { + + fun setUsername(username: String): RobolectricLoginRobot = apply { + onView(withId(R.id.user_edit_text)) + .perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard()) + } + + fun setPassword(password: String): RobolectricLoginRobot = apply { + onView(withId(R.id.password_edit_text)) + .perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard()) + } + + fun clickOnLogin() = apply { + onView(withId(R.id.login_cta)) + .perform(ViewActions.click()) + } + + fun assertPassword(password: String) = apply { + onView(withId((R.id.password_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(password))) + } + + fun assertUsername(username: String) = apply { + onView(withId((R.id.user_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(username))) + } + + fun assertLoadingBeforeRequests() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(isDisplayed())) + } + + fun assertNotLoading() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(not(isDisplayed()))) + } + + fun assertErrorIsShown(@StringRes stringResID: Int) = apply { + assertSnackBarIsShownWithText(stringResID) + } + + fun assertErrorIsNotShown() = apply { + assertSnackBarIsNotShown() + } + + fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..ebef811 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.auth.AuthActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AuthActivityInstrumentedTest : AuthActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt new file mode 100644 index 0000000..e71b267 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.home + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.home.MainActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class MainActivityInstrumentedTest : MainActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt new file mode 100644 index 0000000..74b6ace --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.splash.SplashActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class SplashActivityInstrumentedTest : SplashActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModelTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModelTest.kt new file mode 100644 index 0000000..96c2d8f --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModelTest.kt @@ -0,0 +1,216 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.android.testutil.InstantExecutorExtension +import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher +import org.fnives.test.showcase.hilt.core.login.LoginUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import java.util.stream.Stream + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) +internal class AuthViewModelTest { + + private lateinit var sut: AuthViewModel + private lateinit var mockLoginUseCase: LoginUseCase + private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler + + @BeforeEach + fun setUp() { + mockLoginUseCase = mock() + sut = AuthViewModel(mockLoginUseCase) + } + + @DisplayName("GIVEN initialized viewModel WHEN observed THEN loading false other fields are empty") + @Test + fun initialSetup() { + val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + testScheduler.advanceUntilIdle() + + usernameTestObserver.assertNoValue() + passwordTestObserver.assertNoValue() + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN password text WHEN onPasswordChanged is called THEN password livedata is updated") + @Test + fun whenPasswordChangedLiveDataIsUpdated() { + val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onPasswordChanged("a") + sut.onPasswordChanged("al") + testScheduler.advanceUntilIdle() + + usernameTestObserver.assertNoValue() + passwordTestObserver.assertValueHistory("a", "al") + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN username text WHEN onUsernameChanged is called THEN username livedata is updated") + @Test + fun whenUsernameChangedLiveDataIsUpdated() { + val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onUsernameChanged("bla") + sut.onUsernameChanged("blabla") + testScheduler.advanceUntilIdle() + + usernameTestObserver.assertValueHistory("bla", "blabla") + passwordTestObserver.assertNoValue() + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN no password or username WHEN login is Called THEN empty credentials are used in usecase") + @Test + fun noPasswordUsesEmptyStringInLoginUseCase() { + val loadingTestObserver = sut.loading.test() + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @DisplayName("WHEN login is called twice before finishing THEN use case is only called once") + @Test + fun onlyOneLoginIsSentOutWhenClickingRepeatedly() { + runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } + + sut.onLogin() + sut.onLogin() + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @DisplayName("GIVEN password and username WHEN login is called THEN proper credentials are used in usecase") + @Test + fun argumentsArePassedProperlyToLoginUseCase() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + sut.onPasswordChanged("pass") + sut.onUsernameChanged("usr") + testScheduler.advanceUntilIdle() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + runBlocking { + verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("usr", "pass")) + } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @DisplayName("GIVEN AnswerError WHEN login called THEN error is shown") + @Test + fun loginUnexpectedErrorResultsInErrorState() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) + navigateToHomeTestObserver.assertNoValue() + } + + @MethodSource("loginErrorStatusesArguments") + @ParameterizedTest(name = "GIVEN answer success loginStatus {0} WHEN login called THEN error {1} is shown") + fun invalidStatusResultsInErrorState( + loginStatus: LoginStatus, + errorType: AuthViewModel.ErrorType, + ) { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus)) + } + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertValueHistory(Event(errorType)) + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN answer success and login status success WHEN login called THEN navigation event is sent") + @Test + fun successLoginResultsInNavigation() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS)) + } + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertValueHistory(Event(Unit)) + } + + companion object { + + @JvmStatic + fun loginErrorStatusesArguments(): Stream = Stream.of( + Arguments.of(LoginStatus.INVALID_CREDENTIALS, AuthViewModel.ErrorType.INVALID_CREDENTIALS), + Arguments.of(LoginStatus.INVALID_PASSWORD, AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD), + Arguments.of(LoginStatus.INVALID_USERNAME, AuthViewModel.ErrorType.UNSUPPORTED_USERNAME) + ) + } +} diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/home/MainViewModelTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/home/MainViewModelTest.kt new file mode 100644 index 0000000..c557a13 --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/home/MainViewModelTest.kt @@ -0,0 +1,253 @@ +package org.fnives.test.showcase.hilt.ui.home + +import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.android.testutil.InstantExecutorExtension +import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) +internal class MainViewModelTest { + + private lateinit var sut: MainViewModel + private lateinit var mockGetAllContentUseCase: GetAllContentUseCase + private lateinit var mockLogoutUseCase: LogoutUseCase + private lateinit var mockFetchContentUseCase: FetchContentUseCase + private lateinit var mockAddContentToFavouriteUseCase: AddContentToFavouriteUseCase + private lateinit var mockRemoveContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase + private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler + + @BeforeEach + fun setUp() { + mockGetAllContentUseCase = mock() + mockLogoutUseCase = mock() + mockFetchContentUseCase = mock() + mockAddContentToFavouriteUseCase = mock() + mockRemoveContentFromFavouritesUseCase = mock() + sut = MainViewModel( + getAllContentUseCase = mockGetAllContentUseCase, + logoutUseCase = mockLogoutUseCase, + fetchContentUseCase = mockFetchContentUseCase, + addContentToFavouriteUseCase = mockAddContentToFavouriteUseCase, + removeContentFromFavouritesUseCase = mockRemoveContentFromFavouritesUseCase + ) + } + + @DisplayName("WHEN initialization THEN error false other states empty") + @Test + fun initialStateIsCorrect() { + sut.errorMessage.test().assertValue(false) + sut.content.test().assertNoValue() + sut.loading.test().assertNoValue() + sut.navigateToAuth.test().assertNoValue() + } + + @DisplayName("GIVEN initialized viewModel WHEN loading is returned THEN loading is shown") + @Test + fun loadingDataShowsInLoadingUIState() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValue(false) + contentTestObserver.assertNoValue() + loadingTestObserver.assertValue(true) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading then data WHEN observing content THEN proper states are shown") + @Test + fun loadingThenLoadedDataResultsInProperUIStates() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading(), Resource.Success(emptyList()))) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false) + contentTestObserver.assertValueHistory(listOf()) + loadingTestObserver.assertValueHistory(true, false) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading then error WHEN observing content THEN proper states are shown") + @Test + fun loadingThenErrorResultsInProperUIStates() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading(), Resource.Error(Throwable()))) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, true) + contentTestObserver.assertValueHistory(emptyList()) + loadingTestObserver.assertValueHistory(true, false) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading then error then loading then data WHEN observing content THEN proper states are shown") + @Test + fun loadingThenErrorThenLoadingThenDataResultsInProperUIStates() { + val content = listOf( + FavouriteContent(Content(ContentId(""), "", "", ImageUrl("")), false) + ) + whenever(mockGetAllContentUseCase.get()).doReturn( + flowOf( + Resource.Loading(), + Resource.Error(Throwable()), + Resource.Loading(), + Resource.Success(content) + ) + ) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, true, false) + contentTestObserver.assertValueHistory(emptyList(), content) + loadingTestObserver.assertValueHistory(true, false, true, false) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading viewModel WHEN refreshing THEN usecase is not called") + @Test + fun fetchIsIgnoredIfViewModelIsStillLoading() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onRefresh() + testScheduler.advanceUntilIdle() + + verifyNoInteractions(mockFetchContentUseCase) + } + + @DisplayName("GIVEN non loading viewModel WHEN refreshing THEN usecase is called") + @Test + fun fetchIsCalledIfViewModelIsLoaded() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onRefresh() + testScheduler.advanceUntilIdle() + + verify(mockFetchContentUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockFetchContentUseCase) + } + + @DisplayName("GIVEN loading viewModel WHEN loging out THEN usecase is called") + @Test + fun loadingViewModelStillCalsLogout() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onLogout() + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @DisplayName("GIVEN non loading viewModel WHEN loging out THEN usecase is called") + @Test + fun nonLoadingViewModelStillCalsLogout() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onLogout() + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @DisplayName("GIVEN success content list viewModel WHEN toggling a nonexistent contentId THEN nothing happens") + @Test + fun interactionWithNonExistentContentIdIsIgnored() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("c")) + testScheduler.advanceUntilIdle() + + verifyNoInteractions(mockRemoveContentFromFavouritesUseCase) + verifyNoInteractions(mockAddContentToFavouriteUseCase) + } + + @DisplayName("GIVEN success content list viewModel WHEN toggling a favourite contentId THEN remove favourite usecase is called") + @Test + fun togglingFavouriteContentCallsRemoveFromFavourite() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("b")) + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockRemoveContentFromFavouritesUseCase, times(1)).invoke(ContentId("b")) } + verifyNoMoreInteractions(mockRemoveContentFromFavouritesUseCase) + verifyNoInteractions(mockAddContentToFavouriteUseCase) + } + + @DisplayName("GIVEN success content list viewModel WHEN toggling a not favourite contentId THEN add favourite usecase is called") + @Test + fun togglingNonFavouriteContentCallsAddToFavourite() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("a")) + testScheduler.advanceUntilIdle() + + verifyNoInteractions(mockRemoveContentFromFavouritesUseCase) + runBlocking { verify(mockAddContentToFavouriteUseCase, times(1)).invoke(ContentId("a")) } + verifyNoMoreInteractions(mockAddContentToFavouriteUseCase) + } +} diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/shared/EventTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/shared/EventTest.kt new file mode 100644 index 0000000..6e76fb5 --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/shared/EventTest.kt @@ -0,0 +1,53 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +internal class EventTest { + + @DisplayName("GIVEN event WHEN consumed is called THEN value is returned") + @Test + fun consumedReturnsValue() { + val expected = "a" + + val actual = Event("a").consume() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN consumed event WHEN consumed is called THEN null is returned") + @Test + fun consumedEventReturnsNull() { + val expected: String? = null + val event = Event("a") + event.consume() + + val actual = event.consume() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN event WHEN peek is called THEN value is returned") + @Test + fun peekReturnsValue() { + val expected = "a" + + val actual = Event("a").peek() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN consumed event WHEN peek is called THEN value is returned") + @Test + fun consumedEventPeekedReturnsValue() { + val expected = "a" + val event = Event("a") + event.consume() + + val actual = event.peek() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModelTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModelTest.kt new file mode 100644 index 0000000..b0172ce --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModelTest.kt @@ -0,0 +1,63 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.fnives.test.showcase.android.testutil.InstantExecutorExtension +import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) +internal class SplashViewModelTest { + + private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase + private lateinit var sut: SplashViewModel + private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler + + @BeforeEach + fun setUp() { + mockIsUserLoggedInUseCase = mock() + sut = SplashViewModel(mockIsUserLoggedInUseCase) + } + + @DisplayName("GIVEN not logged in user WHEN splash started THEN after half a second navigated to authentication") + @Test + fun loggedOutUserGoesToAuthentication() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + val navigateToTestObserver = sut.navigateTo.test() + + testScheduler.advanceTimeBy(501) + + navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) + } + + @DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home") + @Test + fun loggedInUserGoesToHome() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) + val navigateToTestObserver = sut.navigateTo.test() + + testScheduler.advanceTimeBy(501) + + navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.HOME)) + } + + @DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent") + @Test + fun withoutEnoughTimeNoNavigationHappens() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + val navigateToTestObserver = sut.navigateTo.test() + + testScheduler.advanceTimeBy(500) + + navigateToTestObserver.assertNoValue() + } +} diff --git a/hilt/hilt-app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/hilt/hilt-app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/hilt/hilt-app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/hilt/hilt-app/src/test/resources/robolectric.properties b/hilt/hilt-app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..e5adbbb --- /dev/null +++ b/hilt/hilt-app/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +sdk=22,28 +instrumentedPackages=androidx.loader.content +application = dagger.hilt.android.testing.HiltTestApplication \ No newline at end of file diff --git a/hilt/hilt-core/.gitignore b/hilt/hilt-core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-core/build.gradle b/hilt/hilt-core/build.gradle new file mode 100644 index 0000000..4ddaa58 --- /dev/null +++ b/hilt/hilt-core/build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'java-library' + id 'kotlin' + id 'kotlin-kapt' + id 'java-test-fixtures' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +kapt { + correctErrorTypes = true +} + +dependencies { + api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + api project(":model") + implementation project(":hilt:hilt-network") + + applyCoreTestDependenciesTo(this) + + // hilt + implementation "com.google.dagger:hilt-core:$hilt_version" + kapt "com.google.dagger:hilt-compiler:$hilt_version" + def reloadable_module_version = "0.1.0" + implementation "org.fnives.library.reloadable.module:annotation:$reloadable_module_version" + kapt "org.fnives.library.reloadable.module:annotation-processor:$reloadable_module_version" + + testImplementation project(':mockserver') + testFixturesApi testFixtures(project(":hilt:hilt-network")) + kaptTest "com.google.dagger:dagger-compiler:$hilt_version" +} \ No newline at end of file diff --git a/hilt/hilt-core/consumer-rules.pro b/hilt/hilt-core/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-core/proguard-rules.pro b/hilt/hilt-core/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-core/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCase.kt new file mode 100644 index 0000000..5363bae --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCase.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.core.content + +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import javax.inject.Inject + +class AddContentToFavouriteUseCase @Inject internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage, +) { + + suspend fun invoke(contentId: ContentId) = + favouriteContentLocalStorage.markAsFavourite(contentId) +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/ContentRepository.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/ContentRepository.kt new file mode 100644 index 0000000..f958f2b --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/ContentRepository.kt @@ -0,0 +1,41 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.fnives.test.showcase.hilt.core.di.LoggedInModuleInject +import org.fnives.test.showcase.hilt.core.shared.Optional +import org.fnives.test.showcase.hilt.core.shared.mapIntoResource +import org.fnives.test.showcase.hilt.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.shared.Resource + +internal class ContentRepository @LoggedInModuleInject internal constructor( + private val contentRemoteSource: ContentRemoteSource, +) { + + private val mutableContentFlow = MutableStateFlow(Optional>(null)) + private val requestFlow: Flow>> = flow { + emit(Resource.Loading()) + val response = wrapIntoAnswer { contentRemoteSource.get() }.mapIntoResource() + if (response is Resource.Success) { + mutableContentFlow.value = Optional(response.data) + } + emit(response) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val contents: Flow>> = mutableContentFlow.flatMapLatest { + if (it.item != null) flowOf(Resource.Success(it.item)) else requestFlow + } + .distinctUntilChanged() + + fun fetch() { + mutableContentFlow.value = Optional(null) + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCase.kt new file mode 100644 index 0000000..3e61801 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCase.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.core.content + +import javax.inject.Inject + +class FetchContentUseCase @Inject internal constructor(private val contentRepository: ContentRepository) { + + fun invoke() = contentRepository.fetch() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCase.kt new file mode 100644 index 0000000..5315459 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCase.kt @@ -0,0 +1,47 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource +import javax.inject.Inject + +class GetAllContentUseCase @Inject internal constructor( + private val contentRepository: ContentRepository, + private val favouriteContentLocalStorage: FavouriteContentLocalStorage, +) { + + fun get(): Flow>> = + contentRepository.contents.combine( + favouriteContentLocalStorage.observeFavourites(), + ::combineContentWithFavourites + ) + .distinctUntilChanged() + + companion object { + private fun combineContentWithFavourites( + contentResource: Resource>, + favouriteContents: List, + ): Resource> = + when (contentResource) { + is Resource.Error -> Resource.Error(contentResource.error) + is Resource.Loading -> Resource.Loading() + is Resource.Success -> + Resource.Success( + combineContentWithFavourites(contentResource.data, favouriteContents) + ) + } + + private fun combineContentWithFavourites( + content: List, + favourite: List, + ): List = + content.map { + FavouriteContent(content = it, isFavourite = favourite.contains(it.id)) + } + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCase.kt new file mode 100644 index 0000000..7d8b75d --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCase.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.hilt.core.content + +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import javax.inject.Inject + +class RemoveContentFromFavouritesUseCase @Inject internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage, +) { + + suspend fun invoke(contentId: ContentId) { + favouriteContentLocalStorage.deleteAsFavourite(contentId) + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/CoreModule.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/CoreModule.kt new file mode 100644 index 0000000..b01fbb3 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/CoreModule.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.hilt.core.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.hilt.core.session.SessionExpirationAdapter +import org.fnives.test.showcase.hilt.core.storage.NetworkSessionLocalStorageAdapter +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage + +@InstallIn(SingletonComponent::class) +@Module +object CoreModule { + + @Provides + internal fun bindNetworkSessionLocalStorageAdapter( + networkSessionLocalStorageAdapter: NetworkSessionLocalStorageAdapter + ): NetworkSessionLocalStorage = networkSessionLocalStorageAdapter + + @Provides + internal fun bindNetworkSessionExpirationListener( + sessionExpirationAdapter: SessionExpirationAdapter + ): NetworkSessionExpirationListener = sessionExpirationAdapter + + @Provides + fun provideLogoutUseCase( + storage: UserDataLocalStorage, + reloadLoggedInModuleInjectModule: ReloadLoggedInModuleInjectModule + ): LogoutUseCase = LogoutUseCase(storage, reloadLoggedInModuleInjectModule) +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/LoggedInModuleInject.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/LoggedInModuleInject.kt new file mode 100644 index 0000000..c6cca05 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/LoggedInModuleInject.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.core.di + +import org.fnives.library.reloadable.module.annotation.ReloadableModule + +@ReloadableModule +@Target(AnnotationTarget.CONSTRUCTOR) +@Retention(AnnotationRetention.SOURCE) +annotation class LoggedInModuleInject diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCase.kt new file mode 100644 index 0000000..7d1470d --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCase.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import javax.inject.Inject + +class IsUserLoggedInUseCase @Inject internal constructor( + private val userDataLocalStorage: UserDataLocalStorage, +) { + + fun invoke(): Boolean = userDataLocalStorage.session != null +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LoginUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LoginUseCase.kt new file mode 100644 index 0000000..e116006 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LoginUseCase.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSource +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import javax.inject.Inject + +class LoginUseCase @Inject internal constructor( + private val loginRemoteSource: LoginRemoteSource, + private val userDataLocalStorage: UserDataLocalStorage, +) { + + suspend fun invoke(credentials: LoginCredentials): Answer { + if (credentials.username.isBlank()) return Answer.Success(LoginStatus.INVALID_USERNAME) + if (credentials.password.isBlank()) return Answer.Success(LoginStatus.INVALID_PASSWORD) + + return wrapIntoAnswer { + when (val response = loginRemoteSource.login(credentials)) { + LoginStatusResponses.InvalidCredentials -> LoginStatus.INVALID_CREDENTIALS + is LoginStatusResponses.Success -> { + userDataLocalStorage.session = response.session + LoginStatus.SUCCESS + } + } + } + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCase.kt new file mode 100644 index 0000000..3e5aecd --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCase.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.di.ReloadLoggedInModuleInjectModule +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage + +class LogoutUseCase( + private val storage: UserDataLocalStorage, + private val reloadLoggedInModuleInjectModule: ReloadLoggedInModuleInjectModule, +) { + + suspend fun invoke() { + reloadLoggedInModuleInjectModule.reload() + storage.session = null + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapter.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapter.kt new file mode 100644 index 0000000..dcfb881 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapter.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.core.session + +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import javax.inject.Inject + +internal class SessionExpirationAdapter @Inject constructor( + private val sessionExpirationListener: SessionExpirationListener +) : NetworkSessionExpirationListener { + + override fun onSessionExpired() = sessionExpirationListener.onSessionExpired() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationListener.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationListener.kt new file mode 100644 index 0000000..057c913 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationListener.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.hilt.core.session + +interface SessionExpirationListener { + fun onSessionExpired() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtils.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtils.kt new file mode 100644 index 0000000..a20edc0 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtils.kt @@ -0,0 +1,26 @@ +package org.fnives.test.showcase.hilt.core.shared + +import kotlinx.coroutines.CancellationException +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource + +@Suppress("RethrowCaughtException") +internal suspend fun wrapIntoAnswer(callback: suspend () -> T): Answer = + try { + Answer.Success(callback()) + } catch (networkException: NetworkException) { + Answer.Error(networkException) + } catch (parsingException: ParsingException) { + Answer.Error(parsingException) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + Answer.Error(UnexpectedException(throwable)) + } + +internal fun Answer.mapIntoResource() = when (this) { + is Answer.Error -> Resource.Error(error) + is Answer.Success -> Resource.Success(data) +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/Optional.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/Optional.kt new file mode 100644 index 0000000..f680a47 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/Optional.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.hilt.core.shared + +internal class Optional(val item: T?) diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/UnexpectedException.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/UnexpectedException.kt new file mode 100644 index 0000000..ed24435 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/UnexpectedException.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.core.shared + +class UnexpectedException(cause: Throwable) : RuntimeException(cause.message, cause) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return this.cause == (other as UnexpectedException).cause + } + + override fun hashCode(): Int = super.hashCode() + cause.hashCode() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapter.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapter.kt new file mode 100644 index 0000000..9f884b6 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapter.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.core.storage + +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.model.session.Session +import javax.inject.Inject + +internal class NetworkSessionLocalStorageAdapter @Inject constructor( + private val userDataLocalStorage: UserDataLocalStorage, +) : NetworkSessionLocalStorage { + + override var session: Session? + get() = userDataLocalStorage.session + set(value) { + userDataLocalStorage.session = value + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/UserDataLocalStorage.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/UserDataLocalStorage.kt new file mode 100644 index 0000000..ee108fc --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/UserDataLocalStorage.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.hilt.core.storage + +import org.fnives.test.showcase.model.session.Session + +interface UserDataLocalStorage { + var session: Session? +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/content/FavouriteContentLocalStorage.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/content/FavouriteContentLocalStorage.kt new file mode 100644 index 0000000..3c6da86 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/content/FavouriteContentLocalStorage.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.core.storage.content + +import kotlinx.coroutines.flow.Flow +import org.fnives.test.showcase.model.content.ContentId + +interface FavouriteContentLocalStorage { + + fun observeFavourites(): Flow> + + suspend fun markAsFavourite(contentId: ContentId) + + suspend fun deleteAsFavourite(contentId: ContentId) +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCaseTest.kt new file mode 100644 index 0000000..af4e3a7 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCaseTest.kt @@ -0,0 +1,59 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class AddContentToFavouriteUseCaseTest { + + private lateinit var sut: AddContentToFavouriteUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = AddContentToFavouriteUseCase(mockFavouriteContentLocalStorage) + } + + @DisplayName("WHEN nothing happens THEN the storage is not touched") + @Test + fun initializationDoesntAffectStorage() { + verifyNoInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN contentId WHEN called THEN storage is called") + @Test + fun contentIdIsDelegatedToStorage() = runTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).markAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN throwing local storage WHEN thrown THEN its propagated") + @Test + fun storageThrowingIsPropagated() = runTest { + whenever(mockFavouriteContentLocalStorage.markAsFavourite(ContentId("a"))).doThrow( + RuntimeException() + ) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/ContentRepositoryTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/ContentRepositoryTest.kt new file mode 100644 index 0000000..f998c47 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/ContentRepositoryTest.kt @@ -0,0 +1,152 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.shared.UnexpectedException +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class ContentRepositoryTest { + + private lateinit var sut: ContentRepository + private lateinit var mockContentRemoteSource: ContentRemoteSource + + @BeforeEach + fun setUp() { + mockContentRemoteSource = mock() + sut = ContentRepository(mockContentRemoteSource) + } + + @DisplayName("GIVEN no interaction THEN remote source is not called") + @Test + fun fetchingIsLazy() { + verifyNoMoreInteractions(mockContentRemoteSource) + } + + @DisplayName("GIVEN content response WHEN content observed THEN loading AND data is returned") + @Test + fun happyFlow() = runTest { + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) + ) + whenever(mockContentRemoteSource.get()).doReturn( + listOf(Content(ContentId("a"), "", "", ImageUrl(""))) + ) + + val actual = sut.contents.take(2).toList() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN content error WHEN content observed THEN loading AND data is returned") + @Test + fun errorFlow() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + whenever(mockContentRemoteSource.get()).doThrow(exception) + + val actual = sut.contents.take(2).toList() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN saved cache WHEN collected THEN cache is returned") + @Test + fun verifyCaching() = runTest { + val content = Content(ContentId("1"), "", "", ImageUrl("")) + val expected = listOf(Resource.Success(listOf(content))) + whenever(mockContentRemoteSource.get()).doReturn(listOf(content)) + sut.contents.take(2).toList() + + val actual = sut.contents.take(1).toList() + + verify(mockContentRemoteSource, times(1)).get() + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN no response from remote source WHEN content observed THEN loading is returned") + @Test + fun loadingIsShownBeforeTheRequestIsReturned() = runTest { + val expected = Resource.Loading>() + val suspendedRequest = CompletableDeferred() + whenever(mockContentRemoteSource.get()).doSuspendableAnswer { + suspendedRequest.await() + emptyList() + } + + val actual = sut.contents.take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + suspendedRequest.complete(Unit) + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") + @Test + fun whenFetchingRequestIsCalledAgain() = runTest() { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + val actual = async { + sut.contents.take(4).toList() + } + advanceUntilIdle() + sut.fetch() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") + @Test + fun noAdditionalItemsEmitted() = runTest { + val exception = RuntimeException() + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + val actual = async(coroutineContext) { sut.contents.take(5).toList() } + advanceUntilIdle() + sut.fetch() + advanceUntilIdle() + + Assertions.assertFalse(actual.isCompleted) + actual.cancel() + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCaseTest.kt new file mode 100644 index 0000000..5955698 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCaseTest.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class FetchContentUseCaseTest { + + private lateinit var sut: FetchContentUseCase + private lateinit var mockContentRepository: ContentRepository + + @BeforeEach + fun setUp() { + mockContentRepository = mock() + sut = FetchContentUseCase(mockContentRepository) + } + + @DisplayName("WHEN nothing happens THEN the storage is not touched") + @Test + fun initializationDoesntAffectRepository() { + verifyNoInteractions(mockContentRepository) + } + + @DisplayName("WHEN called THEN repository is called") + @Test + fun whenCalledRepositoryIsFetched() = runTest { + sut.invoke() + + verify(mockContentRepository, times(1)).fetch() + verifyNoMoreInteractions(mockContentRepository) + } + + @DisplayName("GIVEN throwing local storage WHEN thrown THEN its thrown") + @Test + fun whenRepositoryThrowsUseCaseAlsoThrows() = runTest { + whenever(mockContentRepository.fetch()).doThrow(RuntimeException()) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke() } + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCaseTest.kt new file mode 100644 index 0000000..7932daf --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCaseTest.kt @@ -0,0 +1,222 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class GetAllContentUseCaseTest { + + private lateinit var sut: GetAllContentUseCase + private lateinit var mockContentRepository: ContentRepository + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + private lateinit var contentFlow: MutableStateFlow>> + private lateinit var favouriteContentIdFlow: MutableStateFlow> + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + mockContentRepository = mock() + favouriteContentIdFlow = MutableStateFlow(emptyList()) + contentFlow = MutableStateFlow(Resource.Loading()) + whenever(mockFavouriteContentLocalStorage.observeFavourites()).doReturn( + favouriteContentIdFlow + ) + whenever(mockContentRepository.contents).doReturn(contentFlow) + sut = GetAllContentUseCase(mockContentRepository, mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN loading AND empty favourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithNoFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN loading AND listOfFavourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN error AND empty favourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithNoFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = emptyList() + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN error AND listOfFavourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("b")) + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN listOfContent AND empty favourite WHEN observed THEN favourites are returned") + @Test + fun successResourceWithNoFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN listOfContent AND other favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithDifferentFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("x")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN listOfContent AND same favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithSameFavouritesResultsInFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, true) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN loading then data then added favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndAddingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, false))), + Resource.Success(listOf(FavouriteContent(content, true))) + ) + + val actual = async(coroutineContext) { + sut.get().take(3).toList() + } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + favouriteContentIdFlow.value = listOf(ContentId("a")) + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } + + @DisplayName("GIVEN loading then data then removed favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndRemovingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Success(listOf(FavouriteContent(content, false))) + ) + + val actual = async(coroutineContext) { + sut.get().take(3).toList() + } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + favouriteContentIdFlow.value = emptyList() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } + + @DisplayName("GIVEN loading then data then loading WHEN observed THEN loading then correct favourites then loading are returned") + @Test + fun loadingThenDataThenLoadingReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Loading() + ) + + val actual = async(coroutineContext) { + sut.get().take(3).toList() + } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + contentFlow.value = Resource.Loading() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCaseTest.kt new file mode 100644 index 0000000..598a10f --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCaseTest.kt @@ -0,0 +1,57 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class RemoveContentFromFavouritesUseCaseTest { + + private lateinit var sut: RemoveContentFromFavouritesUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = RemoveContentFromFavouritesUseCase(mockFavouriteContentLocalStorage) + } + + @DisplayName("WHEN nothing happens THEN the storage is not touched") + @Test + fun initializationDoesntAffectStorage() { + verifyNoInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN contentId WHEN called THEN storage is called") + @Test + fun givenContentIdCallsStorage() = runTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).deleteAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN throwing local storage WHEN thrown THEN its propogated") + @Test + fun storageExceptionThrowingIsPropogated() = runTest { + whenever(mockFavouriteContentLocalStorage.deleteAsFavourite(ContentId("a"))).doThrow(RuntimeException()) + + Assertions.assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineContentRepositoryTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineContentRepositoryTest.kt new file mode 100644 index 0000000..097b3f4 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineContentRepositoryTest.kt @@ -0,0 +1,167 @@ +package org.fnives.test.showcase.hilt.core.content + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.shared.UnexpectedException +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class TurbineContentRepositoryTest { + + private lateinit var sut: ContentRepository + private lateinit var mockContentRemoteSource: ContentRemoteSource + + @BeforeEach + fun setUp() { + mockContentRemoteSource = mock() + sut = ContentRepository(mockContentRemoteSource) + } + + @DisplayName("GIVEN no interaction THEN remote source is not called") + @Test + fun fetchingIsLazy() { + verifyNoMoreInteractions(mockContentRemoteSource) + } + + @DisplayName("GIVEN content response WHEN content observed THEN loading AND data is returned") + @Test + fun happyFlow() = runTest { + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) + ) + whenever(mockContentRemoteSource.get()).doReturn( + listOf(Content(ContentId("a"), "", "", ImageUrl(""))) + ) + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN content error WHEN content observed THEN loading AND data is returned") + @Test + fun errorFlow() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + whenever(mockContentRemoteSource.get()).doThrow(exception) + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN saved cache WHEN collected THEN cache is returned") + @Test + fun verifyCaching() = runTest { + val content = Content(ContentId("1"), "", "", ImageUrl("")) + val expected = listOf(Resource.Success(listOf(content))) + whenever(mockContentRemoteSource.get()).doReturn(listOf(content)) + sut.contents.take(2).toList() + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + verify(mockContentRemoteSource, times(1)).get() + } + + @DisplayName("GIVEN no response from remote source WHEN content observed THEN loading is returned") + @Test + fun loadingIsShownBeforeTheRequestIsReturned() = runTest { + val expected = listOf(Resource.Loading>()) + val suspendedRequest = CompletableDeferred() + whenever(mockContentRemoteSource.get()).doSuspendableAnswer { + suspendedRequest.await() + emptyList() + } + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + suspendedRequest.complete(Unit) + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") + @Test + fun whenFetchingRequestIsCalledAgain() = runTest(UnconfinedTestDispatcher()) { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + sut.contents.test { + sut.fetch() + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + } + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") + @Test + fun noAdditionalItemsEmitted() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + sut.contents.test { + sut.fetch() + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineGetAllContentUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineGetAllContentUseCaseTest.kt new file mode 100644 index 0000000..5fa3847 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineGetAllContentUseCaseTest.kt @@ -0,0 +1,230 @@ +package org.fnives.test.showcase.hilt.core.content + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class TurbineGetAllContentUseCaseTest { + + private lateinit var sut: GetAllContentUseCase + private lateinit var mockContentRepository: ContentRepository + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + private lateinit var contentFlow: MutableStateFlow>> + private lateinit var favouriteContentIdFlow: MutableStateFlow> + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + mockContentRepository = mock() + favouriteContentIdFlow = MutableStateFlow(emptyList()) + contentFlow = MutableStateFlow(Resource.Loading()) + whenever(mockFavouriteContentLocalStorage.observeFavourites()).doReturn( + favouriteContentIdFlow + ) + whenever(mockContentRepository.contents).doReturn(contentFlow) + sut = GetAllContentUseCase(mockContentRepository, mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN loading AND empty favourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithNoFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = listOf(Resource.Loading>()) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading AND listOfFavourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + contentFlow.value = Resource.Loading() + val expected = listOf(Resource.Loading>()) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN error AND empty favourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithNoFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = emptyList() + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = listOf(Resource.Error>(exception)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN error AND listOfFavourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("b")) + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = listOf(Resource.Error>(exception)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND empty favourite WHEN observed THEN favourites are returned") + @Test + fun successResourceWithNoFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND other favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithDifferentFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("x")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND same favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithSameFavouritesResultsInFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, true) + ) + val expected = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading then data then added favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndAddingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, false))), + Resource.Success(listOf(FavouriteContent(content, true))) + ) + + sut.get().test { + contentFlow.value = Resource.Success(listOf(content)) + favouriteContentIdFlow.value = listOf(ContentId("a")) + + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading then data then removed favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndRemovingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Success(listOf(FavouriteContent(content, false))) + ) + + sut.get().test { + contentFlow.value = Resource.Success(listOf(content)) + favouriteContentIdFlow.value = emptyList() + + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading then data then loading WHEN observed THEN loading then correct favourites then loading are returned") + @Test + fun loadingThenDataThenLoadingReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Loading() + ) + + sut.get().test { + contentFlow.value = Resource.Success(listOf(content)) + contentFlow.value = Resource.Loading() + + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/di/TestCoreComponent.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/di/TestCoreComponent.kt new file mode 100644 index 0000000..452a98c --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/di/TestCoreComponent.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.hilt.core.di + +import dagger.BindsInstance +import dagger.Component +import org.fnives.test.showcase.hilt.core.login.LogoutUseCaseTest +import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.HiltNetworkModule +import javax.inject.Singleton + +@Singleton +@Component(modules = [CoreModule::class, HiltNetworkModule::class, ReloadLoggedInModuleInjectModuleImpl::class, BindsBaseOkHttpClient::class]) +internal interface TestCoreComponent { + + @Component.Builder + interface Builder { + + @BindsInstance + fun setBaseUrl(baseUrl: String): Builder + + @BindsInstance + fun setEnableLogging(enableLogging: Boolean): Builder + + @BindsInstance + fun setSessionExpirationListener(listener: SessionExpirationListener): Builder + + @BindsInstance + fun setUserDataLocalStorage(storage: UserDataLocalStorage): Builder + + fun build(): TestCoreComponent + } + + fun inject(logoutUseCaseTest: LogoutUseCaseTest) +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCaseTest.kt new file mode 100644 index 0000000..b2dbed2 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCaseTest.kt @@ -0,0 +1,66 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class IsUserLoggedInUseCaseTest { + + private lateinit var sut: IsUserLoggedInUseCase + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = IsUserLoggedInUseCase(mockUserDataLocalStorage) + } + + @DisplayName("WHEN nothing is called THEN storage is not called") + @Test + fun creatingDoesntAffectStorage() { + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN session data saved WHEN is user logged in checked THEN true is returned") + @Test + fun sessionInStorageResultsInLoggedIn() { + whenever(mockUserDataLocalStorage.session).doReturn(Session("a", "b")) + + val actual = sut.invoke() + + Assertions.assertEquals(true, actual) + } + + @DisplayName("GIVEN no session data saved WHEN is user logged in checked THEN false is returned") + @Test + fun noSessionInStorageResultsInLoggedOut() { + whenever(mockUserDataLocalStorage.session).doReturn(null) + + val actual = sut.invoke() + + Assertions.assertEquals(false, actual) + } + + @DisplayName("GIVEN no session THEN session THEN no session WHEN is user logged in checked over again THEN every return is correct") + @Test + fun multipleSessionSettingsResultsInCorrectResponses() { + whenever(mockUserDataLocalStorage.session).doReturn(null) + val actual1 = sut.invoke() + whenever(mockUserDataLocalStorage.session).doReturn(Session("", "")) + val actual2 = sut.invoke() + whenever(mockUserDataLocalStorage.session).doReturn(null) + val actual3 = sut.invoke() + + Assertions.assertEquals(false, actual1) + Assertions.assertEquals(true, actual2) + Assertions.assertEquals(false, actual3) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LoginUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LoginUseCaseTest.kt new file mode 100644 index 0000000..2cb7707 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LoginUseCaseTest.kt @@ -0,0 +1,105 @@ +package org.fnives.test.showcase.hilt.core.login + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.shared.UnexpectedException +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSource +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.model.shared.Answer +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class LoginUseCaseTest { + + private lateinit var sut: LoginUseCase + private lateinit var mockLoginRemoteSource: LoginRemoteSource + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockLoginRemoteSource = mock() + mockUserDataLocalStorage = mock() + sut = LoginUseCase(mockLoginRemoteSource, mockUserDataLocalStorage) + } + + @DisplayName("GIVEN empty username WHEN trying to login THEN invalid username is returned") + @Test + fun emptyUserNameReturnsLoginStatusError() = runTest { + val expected = Answer.Success(LoginStatus.INVALID_USERNAME) + + val actual = sut.invoke(LoginCredentials("", "a")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockLoginRemoteSource) + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN empty password WHEN trying to login THEN invalid password is returned") + @Test + fun emptyPasswordNameReturnsLoginStatusError() = runTest { + val expected = Answer.Success(LoginStatus.INVALID_PASSWORD) + + val actual = sut.invoke(LoginCredentials("a", "")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockLoginRemoteSource) + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN invalid credentials response WHEN trying to login THEN invalid credentials is returned ") + @Test + fun invalidLoginResponseReturnInvalidCredentials() = runTest { + val expected = Answer.Success(LoginStatus.INVALID_CREDENTIALS) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doReturn(LoginStatusResponses.InvalidCredentials) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN success response WHEN trying to login THEN session is saved and success is returned") + @Test + fun validResponseResultsInSavingSessionAndSuccessReturned() = runTest { + val expected = Answer.Success(LoginStatus.SUCCESS) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doReturn(LoginStatusResponses.Success(Session("c", "d"))) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verify(mockUserDataLocalStorage, times(1)).session = Session("c", "d") + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN error response WHEN trying to login THEN session is not touched and error is returned") + @Test + fun invalidResponseResultsInErrorReturned() = runTest { + val exception = RuntimeException() + val expected = Answer.Error(UnexpectedException(exception)) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doThrow(exception) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockUserDataLocalStorage) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCaseTest.kt new file mode 100644 index 0000000..964b9c5 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCaseTest.kt @@ -0,0 +1,71 @@ +package org.fnives.test.showcase.hilt.core.login + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.content.ContentRepository +import org.fnives.test.showcase.hilt.core.di.DaggerTestCoreComponent +import org.fnives.test.showcase.hilt.core.di.TestCoreComponent +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import javax.inject.Inject + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class LogoutUseCaseTest : KoinTest { + + @Inject + lateinit var sut: LogoutUseCase + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + private lateinit var testCoreComponent: TestCoreComponent + + @Inject + lateinit var contentRepository: ContentRepository + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + testCoreComponent = DaggerTestCoreComponent.builder() + .setBaseUrl("https://a.b.com") + .setEnableLogging(true) + .setSessionExpirationListener(mock()) + .setUserDataLocalStorage(mockUserDataLocalStorage) + .build() + testCoreComponent.inject(this) + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @DisplayName("WHEN no call THEN storage is not interacted") + @Test + fun initializedDoesntAffectStorage() { + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("WHEN logout invoked THEN storage is cleared") + @Test + fun logoutResultsInStorageCleaning() = runTest { + val repositoryBefore = contentRepository + + sut.invoke() + + testCoreComponent.inject(this@LogoutUseCaseTest) + val repositoryAfter = contentRepository + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + Assertions.assertNotSame(repositoryBefore, repositoryAfter) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapterTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapterTest.kt new file mode 100644 index 0000000..06d925b --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapterTest.kt @@ -0,0 +1,38 @@ +package org.fnives.test.showcase.hilt.core.session + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions + +@Suppress("TestFunctionName") +internal class SessionExpirationAdapterTest { + + private lateinit var sut: SessionExpirationAdapter + private lateinit var mockSessionExpirationListener: SessionExpirationListener + + @BeforeEach + fun setUp() { + mockSessionExpirationListener = mock() + sut = SessionExpirationAdapter(mockSessionExpirationListener) + } + + @DisplayName("WHEN nothing is changed THEN delegate is not touched") + @Test + fun verifyNoInteractionsIfNoInvocations() { + verifyNoInteractions(mockSessionExpirationListener) + } + + @DisplayName("WHEN onSessionExpired is called THEN delegated is also called") + @Test + fun verifyOnSessionExpirationIsDelegated() { + sut.onSessionExpired() + + verify(mockSessionExpirationListener, times(1)).onSessionExpired() + verifyNoMoreInteractions(mockSessionExpirationListener) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtilsKtTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtilsKtTest.kt new file mode 100644 index 0000000..ac3f96e --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtilsKtTest.kt @@ -0,0 +1,90 @@ +package org.fnives.test.showcase.hilt.core.shared + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class AnswerUtilsKtTest { + + @DisplayName("GIVEN network exception thrown WHEN wrapped into answer THEN answer error is returned") + @Test + fun networkExceptionThrownResultsInError() = runTest { + val exception = NetworkException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN parsing exception thrown WHEN wrapped into answer THEN answer error is returned") + @Test + fun parsingExceptionThrownResultsInError() = runTest { + val exception = ParsingException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN unexpected throwable thrown WHEN wrapped into answer THEN answer error is returned") + @Test + fun unexpectedExceptionThrownResultsInError() = runTest { + val exception = Throwable() + val expected = Answer.Error(UnexpectedException(exception)) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN string WHEN wrapped into answer THEN string answer is returned") + @Test + fun stringIsReturnedWrappedIntoSuccess() = runTest { + val expected = Answer.Success("banan") + + val actual = wrapIntoAnswer { "banan" } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN cancellation exception WHEN wrapped into answer THEN cancellation exception is thrown") + @Test + fun cancellationExceptionResultsInThrowingIt() { + Assertions.assertThrows(CancellationException::class.java) { + runBlocking { wrapIntoAnswer { throw CancellationException() } } + } + } + + @DisplayName("GIVEN success answer WHEN converted into resource THEN Resource success is returned") + @Test + fun successAnswerConvertsToSuccessResource() { + val expected = Resource.Success("alma") + + val actual = Answer.Success("alma").mapIntoResource() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN error answer WHEN converted into resource THEN Resource error is returned") + @Test + fun errorAnswerConvertsToErrorResource() { + val exception = Throwable() + val expected = Resource.Error(exception) + + val actual = Answer.Error(exception).mapIntoResource() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapterTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapterTest.kt new file mode 100644 index 0000000..b6342cb --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapterTest.kt @@ -0,0 +1,59 @@ +package org.fnives.test.showcase.hilt.core.storage + +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class NetworkSessionLocalStorageAdapterTest { + + private lateinit var sut: NetworkSessionLocalStorageAdapter + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = NetworkSessionLocalStorageAdapter(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN null as session WHEN saved THEN its delegated") + @Test + fun settingNullSessionIsDelegated() { + sut.session = null + + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN session WHEN saved THEN its delegated") + @Test + fun settingDataAsSessionIsDelegated() { + val expected = Session("a", "b") + + sut.session = Session("a", "b") + + verify(mockUserDataLocalStorage, times(1)).session = expected + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @DisplayName("WHEN session requested THEN its returned from delegated") + @Test + fun gettingSessionReturnsFromDelegate() { + val expected = Session("a", "b") + whenever(mockUserDataLocalStorage.session).doReturn(expected) + + val actual = sut.session + + Assertions.assertSame(expected, actual) + verify(mockUserDataLocalStorage, times(1)).session + verifyNoMoreInteractions(mockUserDataLocalStorage) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/testutil/AwaitElementEmitCount.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/testutil/AwaitElementEmitCount.kt new file mode 100644 index 0000000..d03a24b --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/testutil/AwaitElementEmitCount.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.core.testutil + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach + +class AwaitElementEmitCount(private var counter: Int) { + + private val completableDeferred = CompletableDeferred() + + init { + assert(counter > 0) + } + + fun attach(flow: Flow): Flow = + flow.onEach { + counter-- + if (counter == 0) { + completableDeferred.complete(Unit) + } + } + + suspend fun await() = completableDeferred.await() +} diff --git a/hilt/hilt-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/hilt/hilt-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/hilt/hilt-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeFavouriteContentLocalStorage.kt b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeFavouriteContentLocalStorage.kt new file mode 100644 index 0000000..8674093 --- /dev/null +++ b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeFavouriteContentLocalStorage.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.hilt.core.integration.fake + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class FakeFavouriteContentLocalStorage : FavouriteContentLocalStorage { + + private val dataFlow = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + init { + dataFlow.tryEmit(emptyList()) + } + + override fun observeFavourites(): Flow> = dataFlow.asSharedFlow() + + override suspend fun markAsFavourite(contentId: ContentId) { + dataFlow.emit(dataFlow.replayCache.first().plus(contentId)) + } + + override suspend fun deleteAsFavourite(contentId: ContentId) { + dataFlow.emit(dataFlow.replayCache.first().minus(contentId)) + } +} diff --git a/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeUserDataLocalStorage.kt b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeUserDataLocalStorage.kt new file mode 100644 index 0000000..1cb3d7b --- /dev/null +++ b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeUserDataLocalStorage.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.core.integration.fake + +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session + +class FakeUserDataLocalStorage(override var session: Session? = null) : UserDataLocalStorage diff --git a/hilt/hilt-network-di-test-util/.gitignore b/hilt/hilt-network-di-test-util/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-network-di-test-util/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/build.gradle b/hilt/hilt-network-di-test-util/build.gradle new file mode 100644 index 0000000..5db617c --- /dev/null +++ b/hilt/hilt-network-di-test-util/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 31 + + defaultConfig { + minSdk 21 + targetSdk 31 + + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + buildConfig = false + } +} + +// since it itself contains the TestUtil it doesn't have tests of it's own +disableTestTasks(this) + +dependencies { + implementation project(":hilt:hilt-network") + implementation "com.google.dagger:hilt-android-testing:$hilt_version" + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation project(':mockserver') + implementation "androidx.test.espresso:espresso-core:$espresso_version" +} \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/consumer-rules.pro b/hilt/hilt-network-di-test-util/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-network-di-test-util/proguard-rules.pro b/hilt/hilt-network-di-test-util/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-network-di-test-util/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/src/main/AndroidManifest.xml b/hilt/hilt-network-di-test-util/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f356b0b --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/HttpsConfigurationModuleTemplate.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/HttpsConfigurationModuleTemplate.kt new file mode 100644 index 0000000..a12603b --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/HttpsConfigurationModuleTemplate.kt @@ -0,0 +1,38 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import okhttp3.tls.HandshakeCertificates +import org.fnives.test.showcase.hilt.network.di.HiltNetworkModule +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup + +// @Module +// @TestInstallIn( +// components = [SingletonComponent::class], +// replaces = [BindsBaseOkHttpClient::class] +// ) +object HttpsConfigurationModuleTemplate { + + lateinit var handshakeCertificates: HandshakeCertificates + +// @Provides +// @Singleton +// @SessionLessQualifier + fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + HiltNetworkModule.provideSessionLessOkHttpClient(enableLogging, platformInterceptor) + .newBuilder() + .sslSocketFactory( + handshakeCertificates.sslSocketFactory(), + handshakeCertificates.trustManager + ) + .build() + + fun startWithHTTPSMockWebServer(): Pair { + val mockServerScenarioSetup = MockServerScenarioSetup() + val url = mockServerScenarioSetup.start(true) + + handshakeCertificates = mockServerScenarioSetup.clientCertificates + ?: throw IllegalStateException("ClientCertificate should be accessable") + + return mockServerScenarioSetup to url + } +} diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt new file mode 100644 index 0000000..ffae218 --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import androidx.annotation.CheckResult +import androidx.test.espresso.IdlingResource +import okhttp3.OkHttpClient +import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier +import org.fnives.test.showcase.hilt.network.di.SessionQualifier +import javax.inject.Inject + +class NetworkSynchronization @Inject constructor( + @SessionQualifier + private val sessionOkhttpClient: OkHttpClient, + @SessionLessQualifier + private val sessionlessOkhttpClient: OkHttpClient +) { + + @CheckResult + fun networkIdlingResources(): List = + OkHttpClientTypes.values() + .map { it to getOkHttpClient(it) } + .associateBy { it.second.dispatcher } + .values + .map { (key, client) -> client.asIdlingResource(key.qualifier) } + + private fun getOkHttpClient(type: OkHttpClientTypes): OkHttpClient = + when (type) { + OkHttpClientTypes.SESSION -> sessionOkhttpClient + OkHttpClientTypes.SESSIONLESS -> sessionlessOkhttpClient + } + + private fun OkHttpClient.asIdlingResource(name: String): IdlingResource = + OkHttp3IdlingResource.create(name, this) + + enum class OkHttpClientTypes(val qualifier: String) { + SESSION("SESSION-NETWORKING"), SESSIONLESS("SESSIONLESS-NETWORKING") + } +} diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt new file mode 100644 index 0000000..69423f2 --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt @@ -0,0 +1,76 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import androidx.annotation.CheckResult +import androidx.annotation.NonNull +import androidx.test.espresso.IdlingResource +import okhttp3.Dispatcher +import okhttp3.OkHttpClient + +/** + * AndroidX version of Jake Wharton's OkHttp3IdlingResource. + * + * Reference: https://github.com/JakeWharton/okhttp-idling-resource/blob/master/src/main/java/com/jakewharton/espresso/OkHttp3IdlingResource.java + */ +class OkHttp3IdlingResource private constructor( + private val name: String, + private val dispatcher: Dispatcher +) : IdlingResource { + @Volatile + var callback: IdlingResource.ResourceCallback? = null + private var isIdleCallbackWasCalled: Boolean = true + + init { + val currentCallback = dispatcher.idleCallback + dispatcher.idleCallback = Runnable { + sleepForDispatcherDefaultCallInRetrofitErrorState() + callback?.onTransitionToIdle() + currentCallback?.run() + isIdleCallbackWasCalled = true + } + } + + override fun getName(): String = name + + override fun isIdleNow(): Boolean { + val isIdle = dispatcher.runningCallsCount() == 0 + if (isIdle) { + // sometime the callback is just not properly called it seems, or maybe sync error. + // if it isn't called Espresso crashes, so we add this here. + callback?.onTransitionToIdle() + } + return isIdle + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { + this.callback = callback + } + + companion object { + /** + * Create a new [IdlingResource] from `client` as `name`. You must register + * this instance using `Espresso.registerIdlingResources`. + */ + @CheckResult + @NonNull + fun create(@NonNull name: String?, @NonNull client: OkHttpClient?): OkHttp3IdlingResource { + if (name == null) throw NullPointerException("name == null") + if (client == null) throw NullPointerException("client == null") + return OkHttp3IdlingResource(name, client.dispatcher) + } + + /** + * This is required, because in case of Errors Retrofit uses Dispatcher.Default to suspendThrow + * see: retrofit2.KotlinExtensions.kt Exception.suspendAndThrow + * Relevant code issue: https://github.com/square/retrofit/blob/6cd6f7d8287f73909614cb7300fcde05f5719750/retrofit/src/main/java/retrofit2/KotlinExtensions.kt#L121 + * This is the current suggested approach to their problem with Unchecked Exceptions + * + * Sadly Dispatcher.Default cannot be replaced yet, so we can't swap it out in tests: + * https://github.com/Kotlin/kotlinx.coroutines/issues/1365 + * + * This brings us to this sleep for now. + */ + fun sleepForDispatcherDefaultCallInRetrofitErrorState() { + Thread.sleep(200L) + } + } +} diff --git a/hilt/hilt-network/.gitignore b/hilt/hilt-network/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-network/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-network/build.gradle b/hilt/hilt-network/build.gradle new file mode 100644 index 0000000..3f13d23 --- /dev/null +++ b/hilt/hilt-network/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java-library' + id 'kotlin' + id 'kotlin-kapt' + id 'java-test-fixtures' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version" + implementation "com.squareup.moshi:moshi:$moshi_version" + kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" + + api project(":model") + + applyNetworkTestDependenciesTo(this) + + // hilt + implementation "com.google.dagger:hilt-core:$hilt_version" + kapt "com.google.dagger:hilt-compiler:$hilt_version" + + kaptTest "com.google.dagger:dagger-compiler:$hilt_version" + + testFixturesApi project(':mockserver') + testFixturesApi "com.squareup.retrofit2:retrofit:$retrofit_version" + testFixturesImplementation "com.google.dagger:hilt-core:$hilt_version" + testFixturesApi "org.junit.jupiter:junit-jupiter-engine:$junit5_version" +} \ No newline at end of file diff --git a/hilt/hilt-network/consumer-rules.pro b/hilt/hilt-network/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-network/proguard-rules.pro b/hilt/hilt-network/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-network/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverter.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverter.kt new file mode 100644 index 0000000..c346b9c --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverter.kt @@ -0,0 +1,36 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.session.Session +import retrofit2.HttpException +import retrofit2.Response +import javax.inject.Inject + +internal class LoginErrorConverter @Inject internal constructor() { + + @Throws(ParsingException::class) + suspend fun invoke(request: suspend () -> Response): LoginStatusResponses = + ExceptionWrapper.wrap { + val response = request() + if (response.code() == 400) { + return@wrap LoginStatusResponses.InvalidCredentials + } else if (!response.isSuccessful) { + throw HttpException(response) + } + + val parsedResponse = try { + response.body()!! + } catch (nullPointerException: NullPointerException) { + throw ParsingException(nullPointerException) + } + + val session = Session( + accessToken = parsedResponse.accessToken, + refreshToken = parsedResponse.refreshToken + ) + LoginStatusResponses.Success(session) + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSource.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSource.kt new file mode 100644 index 0000000..08b4466 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSource.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.auth.LoginCredentials + +interface LoginRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun login(credentials: LoginCredentials): LoginStatusResponses +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceImpl.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceImpl.kt new file mode 100644 index 0000000..7fa250d --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceImpl.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.session.Session +import javax.inject.Inject + +internal class LoginRemoteSourceImpl @Inject internal constructor( + private val loginService: LoginService, + private val loginErrorConverter: LoginErrorConverter +) : LoginRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + override suspend fun login(credentials: LoginCredentials): LoginStatusResponses = + loginErrorConverter.invoke { + loginService.login(CredentialsRequest(user = credentials.username, password = credentials.password)) + } + + @Throws(NetworkException::class, ParsingException::class) + internal suspend fun refresh(refreshToken: String): Session = ExceptionWrapper.wrap { + val response = loginService.refreshToken(refreshToken) + Session(accessToken = response.accessToken, refreshToken = response.refreshToken) + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginService.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginService.kt new file mode 100644 index 0000000..0abe0d4 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginService.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path + +internal interface LoginService { + + @POST("login") + suspend fun login(@Body credentials: CredentialsRequest): Response + + @PUT("login/{token}") + suspend fun refreshToken(@Path("token") token: String): LoginResponse +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/CredentialsRequest.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/CredentialsRequest.kt new file mode 100644 index 0000000..eab25f8 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/CredentialsRequest.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.auth.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal class CredentialsRequest( + @Json(name = "username") + val user: String, + @Json(name = "password") + val password: String +) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginResponse.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginResponse.kt new file mode 100644 index 0000000..c08439e --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginResponse.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.auth.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class LoginResponse( + @Json(name = "accessToken") + val accessToken: String, + @Json(name = "refreshToken") + val refreshToken: String +) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginStatusResponses.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginStatusResponses.kt new file mode 100644 index 0000000..324d44a --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginStatusResponses.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.network.auth.model + +import org.fnives.test.showcase.model.session.Session + +sealed class LoginStatusResponses { + data class Success(val session: Session) : LoginStatusResponses() + object InvalidCredentials : LoginStatusResponses() +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSource.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSource.kt new file mode 100644 index 0000000..e3384fe --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSource.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.network.content + +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.content.Content + +interface ContentRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun get(): List +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImpl.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImpl.kt new file mode 100644 index 0000000..450b899 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImpl.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.hilt.network.content + +import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import javax.inject.Inject + +internal class ContentRemoteSourceImpl @Inject internal constructor( + private val contentService: ContentService +) : ContentRemoteSource { + + override suspend fun get(): List = + ExceptionWrapper.wrap { + contentService.getContent().mapNotNull(::mapResponse) + } + + companion object { + + private fun mapResponse(response: ContentResponse): Content? { + return Content( + id = response.id?.let(::ContentId) ?: return null, + title = response.title ?: return null, + description = response.description ?: return null, + imageUrl = ImageUrl(response.imageUrl ?: return null) + ) + } + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentResponse.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentResponse.kt new file mode 100644 index 0000000..00b8431 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentResponse.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.network.content + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class ContentResponse internal constructor( + @Json(name = "id") + val id: String?, + @Json(name = "title") + val title: String?, + @Json(name = "image") + val imageUrl: String?, + @Json(name = "says") + val description: String? +) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentService.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentService.kt new file mode 100644 index 0000000..8bb1eef --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentService.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.hilt.network.content + +import retrofit2.http.GET + +interface ContentService { + + @GET("content") + suspend fun getContent(): List +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/BindsBaseOkHttpClient.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/BindsBaseOkHttpClient.kt new file mode 100644 index 0000000..6b2721d --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/BindsBaseOkHttpClient.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.network.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient + +@InstallIn(SingletonComponent::class) +@Module +abstract class BindsBaseOkHttpClient { + + @Binds + @SessionLessQualifier + abstract fun bindsSessionLess(okHttpClient: OkHttpClient): OkHttpClient +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/HiltNetworkModule.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/HiltNetworkModule.kt new file mode 100644 index 0000000..198e516 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/HiltNetworkModule.kt @@ -0,0 +1,87 @@ +package org.fnives.test.showcase.hilt.network.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSource +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceImpl +import org.fnives.test.showcase.hilt.network.auth.LoginService +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSourceImpl +import org.fnives.test.showcase.hilt.network.content.ContentService +import org.fnives.test.showcase.hilt.network.session.AuthenticationHeaderInterceptor +import org.fnives.test.showcase.hilt.network.session.SessionAuthenticator +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object HiltNetworkModule { + + @Provides + @Singleton + fun provideConverterFactory(): Converter.Factory = MoshiConverterFactory.create() + + @Provides + @Singleton + fun provideSessionLessOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + OkHttpClient.Builder() + .addInterceptor(platformInterceptor) + .setupLogging(enableLogging) + .build() + + @Provides + @Singleton + @SessionLessQualifier + fun provideSessionLessRetrofit( + baseUrl: String, + converterFactory: Converter.Factory, + @SessionLessQualifier okHttpClient: OkHttpClient, + ): Retrofit = + Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(converterFactory) + .client(okHttpClient) + .build() + + @Provides + @Singleton + @SessionQualifier + internal fun provideSessionOkHttpClient( + @SessionLessQualifier okHttpClient: OkHttpClient, + sessionAuthenticator: SessionAuthenticator, + authenticationHeaderInterceptor: AuthenticationHeaderInterceptor, + ) = + okHttpClient + .newBuilder() + .authenticator(sessionAuthenticator) + .addInterceptor(authenticationHeaderInterceptor) + .build() + + @Provides + @Singleton + @SessionQualifier + fun provideSessionRetrofit(@SessionLessQualifier retrofit: Retrofit, @SessionQualifier okHttpClient: OkHttpClient): Retrofit = + retrofit.newBuilder() + .client(okHttpClient) + .build() + + @Provides + internal fun bindContentRemoteSource(contentRemoteSourceImpl: ContentRemoteSourceImpl): ContentRemoteSource = contentRemoteSourceImpl + + @Provides + internal fun bindLoginRemoteSource(loginRemoteSource: LoginRemoteSourceImpl): LoginRemoteSource = loginRemoteSource + + @Provides + internal fun provideLoginService(@SessionLessQualifier retrofit: Retrofit): LoginService = + retrofit.create(LoginService::class.java) + + @Provides + internal fun provideContentService(@SessionQualifier retrofit: Retrofit): ContentService = + retrofit.create(ContentService::class.java) +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/OkhttpClientExtension.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/OkhttpClientExtension.kt new file mode 100644 index 0000000..0f717a7 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/OkhttpClientExtension.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.di + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor + +internal fun OkHttpClient.Builder.setupLogging(enable: Boolean) = run { + if (enable) { + addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) + } else { + this + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionLessQualifier.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionLessQualifier.kt new file mode 100644 index 0000000..f56bec1 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionLessQualifier.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.network.di + +import javax.inject.Qualifier + +@Qualifier +annotation class SessionLessQualifier diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionQualifier.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionQualifier.kt new file mode 100644 index 0000000..dc36e91 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionQualifier.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.network.di + +import javax.inject.Qualifier + +@Qualifier +annotation class SessionQualifier diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderInterceptor.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderInterceptor.kt new file mode 100644 index 0000000..4671960 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderInterceptor.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.network.session + +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +internal class AuthenticationHeaderInterceptor @Inject internal constructor( + private val authenticationHeaderUtils: AuthenticationHeaderUtils +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(authenticationHeaderUtils.attachToken(chain.request())) +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderUtils.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderUtils.kt new file mode 100644 index 0000000..5b323b4 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderUtils.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.hilt.network.session + +import okhttp3.Request +import javax.inject.Inject + +internal class AuthenticationHeaderUtils @Inject internal constructor( + private val networkSessionLocalStorage: NetworkSessionLocalStorage +) { + + fun hasToken(okhttpRequest: Request): Boolean = + okhttpRequest.header(KEY) == networkSessionLocalStorage.session?.accessToken + + fun attachToken(okhttpRequest: Request): Request = + okhttpRequest.newBuilder() + .header(KEY, networkSessionLocalStorage.session?.accessToken.orEmpty()).build() + + companion object { + private const val KEY = "Authorization" + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionExpirationListener.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionExpirationListener.kt new file mode 100644 index 0000000..7090556 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionExpirationListener.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.network.session + +interface NetworkSessionExpirationListener { + + fun onSessionExpired() +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionLocalStorage.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionLocalStorage.kt new file mode 100644 index 0000000..1dc937e --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionLocalStorage.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.network.session + +import org.fnives.test.showcase.model.session.Session + +interface NetworkSessionLocalStorage { + + var session: Session? +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/SessionAuthenticator.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/SessionAuthenticator.kt new file mode 100644 index 0000000..4714566 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/SessionAuthenticator.kt @@ -0,0 +1,39 @@ +package org.fnives.test.showcase.hilt.network.session + +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceImpl +import javax.inject.Inject + +internal class SessionAuthenticator @Inject internal constructor( + private val networkSessionLocalStorage: NetworkSessionLocalStorage, + private val loginRemoteSource: LoginRemoteSourceImpl, + private val authenticationHeaderUtils: AuthenticationHeaderUtils, + private val networkSessionExpirationListener: NetworkSessionExpirationListener +) : Authenticator { + + @Suppress("SwallowedException") + override fun authenticate(route: Route?, response: Response): Request? { + if (authenticationHeaderUtils.hasToken(response.request)) { + return runBlocking { + try { + val refreshToken = networkSessionLocalStorage.session + ?.refreshToken + .orEmpty() + val newSession = loginRemoteSource.refresh(refreshToken) + networkSessionLocalStorage.session = newSession + return@runBlocking authenticationHeaderUtils.attachToken(response.request) + } catch (throwable: Throwable) { + networkSessionLocalStorage.session = null + networkSessionExpirationListener.onSessionExpired() + return@runBlocking null + } + } + } else { + return authenticationHeaderUtils.attachToken(response.request) + } + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/ExceptionWrapper.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/ExceptionWrapper.kt new file mode 100644 index 0000000..cc03d65 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/ExceptionWrapper.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.hilt.network.shared + +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonEncodingException +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import java.io.EOFException + +internal object ExceptionWrapper { + + @Suppress("RethrowCaughtException") + @Throws(NetworkException::class, ParsingException::class) + suspend fun wrap(request: suspend () -> T) = try { + request() + } catch (jsonDataException: JsonDataException) { + throw ParsingException(jsonDataException) + } catch (jsonEncodingException: JsonEncodingException) { + throw ParsingException(jsonEncodingException) + } catch (eofException: EOFException) { + throw ParsingException(eofException) + } catch (parsingException: ParsingException) { + throw parsingException + } catch (networkException: NetworkException) { + throw networkException + } catch (throwable: Throwable) { + throw NetworkException(throwable) + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/PlatformInterceptor.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/PlatformInterceptor.kt new file mode 100644 index 0000000..51ce022 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/PlatformInterceptor.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.network.shared + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException +import javax.inject.Inject + +class PlatformInterceptor @Inject internal constructor() : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(chain.request().newBuilder().header("Platform", "Android").build()) +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/NetworkException.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/NetworkException.kt new file mode 100644 index 0000000..b21151e --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/NetworkException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.hilt.network.shared.exceptions + +class NetworkException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/ParsingException.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/ParsingException.kt new file mode 100644 index 0000000..154705d --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/ParsingException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.hilt.network.shared.exceptions + +class ParsingException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverterTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverterTest.kt new file mode 100644 index 0000000..b47cef8 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverterTest.kt @@ -0,0 +1,78 @@ +package org.fnives.test.showcase.hilt.network.auth + +import com.squareup.moshi.JsonDataException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.internal.http.RealResponseBody +import okio.Buffer +import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import retrofit2.Response +import java.io.IOException + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +class LoginErrorConverterTest { + + private lateinit var sut: LoginErrorConverter + + @BeforeEach + fun setUp() { + sut = LoginErrorConverter() + } + + @DisplayName("GIVEN throwing lambda WHEN parsing login error THEN network exception is thrown") + @Test + fun generallyThrowingLambdaResultsInNetworkException() { + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { + sut.invoke { throw IOException() } + } + } + } + + @DisplayName("GIVEN jsonException throwing lambda WHEN parsing login error THEN network exception is thrown") + @Test + fun jsonDataThrowingLambdaResultsInParsingException() { + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { + sut.invoke { throw JsonDataException("") } + } + } + } + + @DisplayName("GIVEN 400 error response WHEN parsing login error THEN invalid credentials is returned") + @Test + fun code400ResponseResultsInInvalidCredentials() = runTest { + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.invoke { + val responseBody = RealResponseBody(null, 0, Buffer()) + Response.error(400, responseBody) + } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN parsing login error THEN successful response is returned") + @Test + fun successResponseResultsInSessionResponse() = runTest { + val loginResponse = LoginResponse("a", "r") + val expectedSession = Session(accessToken = loginResponse.accessToken, refreshToken = loginResponse.refreshToken) + val expected = LoginStatusResponses.Success(expectedSession) + + val actual = sut.invoke { + Response.success(200, loginResponse) + } + + Assertions.assertEquals(expected, actual) + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceRefreshActionImplTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceRefreshActionImplTest.kt new file mode 100644 index 0000000..f206dc2 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceRefreshActionImplTest.kt @@ -0,0 +1,99 @@ +package org.fnives.test.showcase.hilt.network.auth + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.inject +import org.mockito.kotlin.mock +import javax.inject.Inject + +@Suppress("TestFunctionName") +class LoginRemoteSourceRefreshActionImplTest { + + @Inject + internal lateinit var sut: LoginRemoteSourceImpl + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mock()) + .build() + .inject(this) + } + + @DisplayName("GIVEN successful response WHEN refresh request is fired THEN session is returned") + @Test + fun successResponseResultsInSession() = runBlocking { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false) + val expected = ContentData.refreshSuccessResponse + + val actual = sut.refresh(ContentData.refreshSuccessResponse.refreshToken) + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN refresh request is fired THEN the request is setup properly") + @Test + fun refreshRequestIsSetupProperly() = runBlocking { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false) + + sut.refresh(ContentData.refreshSuccessResponse.refreshToken) + val request = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("PUT", request.method) + Assertions.assertEquals("Android", request.getHeader("Platform")) + Assertions.assertEquals(null, request.getHeader("Authorization")) + Assertions.assertEquals("/login/${ContentData.refreshSuccessResponse.refreshToken}", request.path) + Assertions.assertEquals("", request.body.readUtf8()) + } + + @DisplayName("GIVEN internal error response WHEN refresh request is fired THEN network exception is thrown") + @Test + fun generalErrorResponseResultsInNetworkException() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error, validateArguments = false) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.refresh(ContentData.refreshSuccessResponse.refreshToken) } + } + } + + @DisplayName("GIVEN invalid json response WHEN refresh request is fired THEN network exception is thrown") + @Test + fun jsonErrorResponseResultsInParsingException() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.UnexpectedJsonAsSuccessResponse, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) } + } + } + + @DisplayName("GIVEN malformed json response WHEN refresh request is fired THEN parsing exception is thrown") + @Test + fun malformedJsonErrorResponseResultsInParsingException() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.MalformedJson, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) } + } + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceTest.kt new file mode 100644 index 0000000..9fec632 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceTest.kt @@ -0,0 +1,146 @@ +package org.fnives.test.showcase.hilt.network.auth + +import com.squareup.moshi.JsonDataException +import kotlinx.coroutines.runBlocking +import okio.EOFException +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.ContentData.createExpectedLoginRequestJson +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.mock +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode +import retrofit2.HttpException +import javax.inject.Inject + +@Suppress("TestFunctionName") +class LoginRemoteSourceTest { + + @Inject + internal lateinit var sut: LoginRemoteSource + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + val mockNetworkSessionLocalStorage = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mock()) + .build() + .inject(this) + } + + @DisplayName("GIVEN successful response WHEN request is fired THEN login status success is returned") + @Test + fun successResponseIsParsedProperly() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = false) + val expected = LoginStatusResponses.Success(ContentData.loginSuccessResponse) + + val actual = sut.login(LoginCredentials(username = "a", password = "b")) + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN request is fired THEN the request is setup properly") + @Test + fun requestProperlySetup() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = false) + + sut.login(LoginCredentials(username = "a", password = "b")) + val request = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("POST", request.method) + Assertions.assertEquals("Android", request.getHeader("Platform")) + Assertions.assertEquals(null, request.getHeader("Authorization")) + Assertions.assertEquals("/login", request.path) + val loginRequest = createExpectedLoginRequestJson(username = "a", password = "b") + JSONAssert.assertEquals( + loginRequest, + request.body.readUtf8(), + JSONCompareMode.NON_EXTENSIBLE + ) + } + + @DisplayName("GIVEN bad request response WHEN request is fired THEN login status invalid credentials is returned") + @Test + fun badRequestMeansInvalidCredentials() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials(username = "a", password = "b"), validateArguments = false) + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.login(LoginCredentials(username = "a", password = "b")) + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN internal error response WHEN request is fired THEN network exception is thrown") + @Test + fun genericErrorMeansNetworkError() { + mockServerScenarioSetup.setScenario(AuthScenario.GenericError(username = "a", password = "b"), validateArguments = false) + + val actual = Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) } + } + + Assertions.assertEquals("HTTP 500 Server Error", actual.message) + Assertions.assertTrue(actual.cause is HttpException) + } + + @DisplayName("GIVEN invalid json response WHEN request is fired THEN network exception is thrown") + @Test + fun invalidJsonMeansParsingException() { + val response = AuthScenario.UnexpectedJsonAsSuccessResponse(username = "a", password = "b") + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val actual = Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) } + } + + Assertions.assertEquals("Expected BEGIN_OBJECT but was BEGIN_ARRAY at path \$", actual.message) + Assertions.assertTrue(actual.cause is JsonDataException) + } + + @DisplayName("GIVEN json response with missing field WHEN request is fired THEN network exception is thrown") + @Test + fun missingFieldJsonMeansParsingException() { + val response = AuthScenario.MissingFieldJson(username = "a", password = "b") + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val actual = Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) } + } + + Assertions.assertEquals("Required value 'accessToken' missing at \$", actual.message) + Assertions.assertTrue(actual.cause is JsonDataException) + } + + @DisplayName("GIVEN malformed json response WHEN request is fired THEN network exception is thrown") + @Test + fun malformedJsonMeansParsingException() { + val response = AuthScenario.MalformedJsonAsSuccessResponse(username = "a", "b") + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val actual = Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", "b")) } + } + + Assertions.assertEquals("End of input", actual.message) + Assertions.assertTrue(actual.cause is EOFException) + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImplTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImplTest.kt new file mode 100644 index 0000000..3f364b8 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImplTest.kt @@ -0,0 +1,123 @@ +package org.fnives.test.showcase.hilt.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.inject +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import javax.inject.Inject + +@Suppress("TestFunctionName") +class ContentRemoteSourceImplTest : KoinTest { + + @Inject + internal lateinit var sut: ContentRemoteSourceImpl + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mock()) + .build() + .inject(this) + } + + @DisplayName("GIVEN successful response WHEN getting content THEN its parsed and returned correctly") + @Test + fun successResponseParsing() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = false) + val expected = ContentData.contentSuccess + + val actual = sut.get() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN getting content THEN the request is setup properly") + @Test + fun successResponseRequestIsCorrect() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = false) + + sut.get() + val request = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("GET", request.method) + Assertions.assertEquals("Android", request.getHeader("Platform")) + Assertions.assertEquals(ContentData.loginSuccessResponse.accessToken, request.getHeader("Authorization")) + Assertions.assertEquals("/content", request.path) + Assertions.assertEquals("", request.body.readUtf8()) + } + + @DisplayName("GIVEN response with missing Field WHEN getting content THEN invalid is ignored others are returned") + @Test + fun dataMissingFieldIsIgnored() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + val response = ContentScenario.SuccessWithMissingFields(usingRefreshedToken = false) + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val expected = ContentData.contentSuccessWithMissingFields + + val actual = sut.get() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN error response WHEN getting content THEN network request is thrown") + @Test + fun errorResponseResultsInNetworkException() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false), validateArguments = false) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.get() } + } + } + + @DisplayName("GIVEN unexpected json response WHEN getting content THEN parsing request is thrown") + @Test + fun unexpectedJSONResultsInParsingException() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + val response = ContentScenario.UnexpectedJsonAsSuccessResponse(usingRefreshedToken = false) + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.get() } + } + } + + @DisplayName("GIVEN malformed json response WHEN getting content THEN parsing request is thrown") + @Test + fun malformedJSONResultsInParsingException() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + val response = ContentScenario.MalformedJsonAsSuccessResponse(usingRefreshedToken = false) + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.get() } + } + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/SessionExpirationTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/SessionExpirationTest.kt new file mode 100644 index 0000000..4d38c35 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/SessionExpirationTest.kt @@ -0,0 +1,109 @@ +package org.fnives.test.showcase.hilt.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import retrofit2.HttpException +import javax.inject.Inject + +@Suppress("TestFunctionName") +class SessionExpirationTest { + + @Inject + internal lateinit var sut: ContentRemoteSourceImpl + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + private lateinit var mockNetworkSessionExpirationListener: NetworkSessionExpirationListener + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + mockNetworkSessionExpirationListener = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mockNetworkSessionExpirationListener) + .build() + .inject(this) + } + + @DisplayName("GIVEN 401 THEN refresh token ok response WHEN content requested THE tokens are refreshed and request retried with new tokens") + @Test + fun successRefreshResultsInRequestRetry() = runBlocking { + var sessionToReturnByMock: Session? = ContentData.loginSuccessResponse + mockServerScenarioSetup.setScenario( + ContentScenario.Unauthorized(usingRefreshedToken = false) + .then(ContentScenario.Success(usingRefreshedToken = true)), + validateArguments = false + ) + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false) + whenever(mockNetworkSessionLocalStorage.session).doAnswer { sessionToReturnByMock } + doAnswer { sessionToReturnByMock = it.arguments[0] as Session? } + .whenever(mockNetworkSessionLocalStorage).session = anyOrNull() + + sut.get() + + mockServerScenarioSetup.takeRequest() + val refreshRequest = mockServerScenarioSetup.takeRequest() + val retryAfterTokenRefreshRequest = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("PUT", refreshRequest.method) + Assertions.assertEquals( + "/login/${ContentData.loginSuccessResponse.refreshToken}", + refreshRequest.path + ) + Assertions.assertEquals(null, refreshRequest.getHeader("Authorization")) + Assertions.assertEquals("Android", refreshRequest.getHeader("Platform")) + Assertions.assertEquals("", refreshRequest.body.readUtf8()) + Assertions.assertEquals( + ContentData.refreshSuccessResponse.accessToken, + retryAfterTokenRefreshRequest.getHeader("Authorization") + ) + verifyNoInteractions(mockNetworkSessionExpirationListener) + } + + @DisplayName("GIVEN 401 THEN failing refresh WHEN content requested THE error is returned and callback is Called") + @Test + fun failingRefreshResultsInSessionExpiration() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false), validateArguments = false) + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error, validateArguments = false) + + val actual = Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.get() } + } + + Assertions.assertEquals("HTTP 401 Client Error", actual.message) + Assertions.assertTrue(actual.cause is HttpException) + verify(mockNetworkSessionLocalStorage, times(3)).session + verify(mockNetworkSessionLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockNetworkSessionLocalStorage) + verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired() + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/testutil/TestNetworkComponent.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/testutil/TestNetworkComponent.kt new file mode 100644 index 0000000..89c3c98 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/testutil/TestNetworkComponent.kt @@ -0,0 +1,44 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import dagger.BindsInstance +import dagger.Component +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceRefreshActionImplTest +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceTest +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSourceImplTest +import org.fnives.test.showcase.hilt.network.content.SessionExpirationTest +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.HiltNetworkModule +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import javax.inject.Singleton + +@Singleton +@Component(modules = [HiltNetworkModule::class, BindsBaseOkHttpClient::class]) +interface TestNetworkComponent { + + @Component.Builder + interface Builder { + + @BindsInstance + fun setBaseUrl(baseUrl: String): Builder + + @BindsInstance + fun setEnableLogging(enableLogging: Boolean): Builder + + @BindsInstance + fun setNetworkSessionLocalStorage(storage: NetworkSessionLocalStorage): Builder + + @BindsInstance + fun setNetworkSessionExpirationListener(listener: NetworkSessionExpirationListener): Builder + + fun build(): TestNetworkComponent + } + + fun inject(contentRemoteSourceImplTest: ContentRemoteSourceImplTest) + + fun inject(sessionExpirationTest: SessionExpirationTest) + + fun inject(loginRemoteSourceRefreshActionImplTest: LoginRemoteSourceRefreshActionImplTest) + + fun inject(loginRemoteSourceTest: LoginRemoteSourceTest) +} diff --git a/hilt/hilt-network/src/test/resources/success_response_login.json b/hilt/hilt-network/src/test/resources/success_response_login.json new file mode 100644 index 0000000..ba930ef --- /dev/null +++ b/hilt/hilt-network/src/test/resources/success_response_login.json @@ -0,0 +1,4 @@ +{ + "accessToken": "login-access", + "refreshToken": "login-refresh" +} \ No newline at end of file diff --git a/hilt/hilt-network/src/testFixtures/java/org/fnives/test/showcase/hilt/network/testutil/MockServerScenarioSetupExtensions.kt b/hilt/hilt-network/src/testFixtures/java/org/fnives/test/showcase/hilt/network/testutil/MockServerScenarioSetupExtensions.kt new file mode 100644 index 0000000..968d283 --- /dev/null +++ b/hilt/hilt-network/src/testFixtures/java/org/fnives/test/showcase/hilt/network/testutil/MockServerScenarioSetupExtensions.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class MockServerScenarioSetupExtensions : BeforeEachCallback, AfterEachCallback { + + lateinit var url: String + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun beforeEach(context: ExtensionContext?) { + mockServerScenarioSetup = MockServerScenarioSetup() + url = mockServerScenarioSetup.start(false) + } + + override fun afterEach(context: ExtensionContext?) { + mockServerScenarioSetup.stop() + } +} diff --git a/settings.gradle b/settings.gradle index 1306dfa..eb7803a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,5 +9,10 @@ include ':test-util-shared-robolectric' include ':test-util-android' include ':test-util-junit5-android' include ':app-shared-test' +include ':hilt:hilt-core' +include ':hilt:hilt-network' +include ':hilt:hilt-app' include ':examplecase:example-navcontroller' include ':examplecase:example-navcontroller-shared-test' +include ':hilt:hilt-network-di-test-util' +include ':hilt:hilt-app-shared-test' From 60cfb46ccf40a7de0ae9cba37acfa44da1c15238 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Tue, 27 Sep 2022 19:12:51 +0300 Subject: [PATCH 2/4] Issue#41 Make sharedTests abstract so they won't be run in themself --- .../migration/MigrationToLatestInstrumentedSharedTest.kt | 2 +- .../showcase/ui/home/MainActivityInstrumentedSharedTest.kt | 2 +- .../showcase/ui/login/AuthActivityInstrumentedSharedTest.kt | 2 +- .../ui/login/codekata/CodeKataAuthActivitySharedTest.kt | 2 +- .../showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt | 2 +- codekata/sharedtests.instructionset.md | 2 +- .../migration/MigrationToLatestInstrumentedSharedTest.kt | 3 ++- .../hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt | 3 ++- .../test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt | 2 +- .../test/shared/ui/home/MainActivityInstrumentedSharedTest.kt | 2 +- .../shared/ui/splash/SplashActivityInstrumentedSharedTest.kt | 2 +- 11 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt index 3c24451..7dbc095 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt @@ -24,7 +24,7 @@ import java.io.IOException * https://developer.android.com/training/data-storage/room/migrating-db-versions */ @RunWith(AndroidJUnit4::class) -open class MigrationToLatestInstrumentedSharedTest : KoinTest { +abstract class MigrationToLatestInstrumentedSharedTest : KoinTest { @get:Rule val helper = SharedMigrationTestRule(instrumentation = InstrumentationRegistry.getInstrumentation()) diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt index 798c9e7..e3fd1c2 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt @@ -23,7 +23,7 @@ import org.junit.rules.RuleChain import org.koin.test.KoinTest @Suppress("TestFunctionName") -open class MainActivityInstrumentedSharedTest : KoinTest { +abstract class MainActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt index 36725e5..99ba24f 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt @@ -18,7 +18,7 @@ import org.junit.rules.RuleChain import org.koin.test.KoinTest @Suppress("TestFunctionName") -open class AuthActivityInstrumentedSharedTest : KoinTest { +abstract class AuthActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt index 07b66a0..eb94e63 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt @@ -14,7 +14,7 @@ import org.koin.test.KoinTest @OptIn(ExperimentalCoroutinesApi::class) @Ignore("CodeKata") @Suppress("EmptyFunctionBlock") -open class CodeKataAuthActivitySharedTest : KoinTest { +abstract class CodeKataAuthActivitySharedTest : KoinTest { @Before fun setup() { diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt index f08761a..fbdde66 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt @@ -18,7 +18,7 @@ import org.junit.rules.RuleChain import org.koin.test.KoinTest @Suppress("TestFunctionName") -open class SplashActivityInstrumentedSharedTest : KoinTest { +abstract class SplashActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/codekata/sharedtests.instructionset.md b/codekata/sharedtests.instructionset.md index 5bbb35c..b251e5b 100644 --- a/codekata/sharedtests.instructionset.md +++ b/codekata/sharedtests.instructionset.md @@ -39,7 +39,7 @@ Let's open `org.fnives.test.showcase.ui.login.codekata.CodeKataAuthActivityShare We can see it's identical as our original `org.fnives.test.showcase.ui.codekata.CodeKataAuthActivityInstrumentedTest`. So let's copy our existing code from the Robolectric test here. For that we can use the body of `org.fnives.test.showcase.ui.RobolectricAuthActivityInstrumentedTest`. -Of course keep the `open` and the `CodeKataAuthActivitySharedTest` class name and package. +Of course keep the `abstract`, the `CodeKataAuthActivitySharedTest` class name and package. We need to modify our robot: ```kotlin // Instead of this: diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt index da60724..2f60e68 100644 --- a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt @@ -21,7 +21,8 @@ import java.io.IOException * https://developer.android.com/training/data-storage/room/migrating-db-versions */ @RunWith(AndroidJUnit4::class) -open class MigrationToLatestInstrumentedSharedTest { +@Suppress("UnnecessaryAbstractClass") +abstract class MigrationToLatestInstrumentedSharedTest { @get:Rule val helper = SharedMigrationTestRule(instrumentation = InstrumentationRegistry.getInstrumentation()) diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt index ac61133..79b4679 100644 --- a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt @@ -7,7 +7,8 @@ import org.junit.Before import org.junit.Rule import javax.inject.Inject -open class NetworkSynchronizedActivityTest { +@Suppress("UnnecessaryAbstractClass") +abstract class NetworkSynchronizedActivityTest { @Inject lateinit var networkSynchronizationHelper: NetworkSynchronizationHelper diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt index f9b7327..aa7ce34 100644 --- a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt @@ -17,7 +17,7 @@ import org.junit.Test import org.junit.rules.RuleChain @Suppress("TestFunctionName") -open class AuthActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { +abstract class AuthActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { private lateinit var activityScenario: ActivityScenario diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt index 128f468..c340211 100644 --- a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt @@ -23,7 +23,7 @@ import org.junit.Test import org.junit.rules.RuleChain @Suppress("TestFunctionName") -open class MainActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { +abstract class MainActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { private lateinit var activityScenario: ActivityScenario diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt index aeadbaa..3446238 100644 --- a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt @@ -18,7 +18,7 @@ import org.junit.Test import org.junit.rules.RuleChain @Suppress("TestFunctionName") -open class SplashActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { +abstract class SplashActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { private lateinit var activityScenario: ActivityScenario From f03c9f7bf231d51430e324e3822e407a3d5ae4e5 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Wed, 28 Sep 2022 15:47:43 +0300 Subject: [PATCH 3/4] Issue#41 Attempt to fix failing tests with compose + update test paths for artifact --- .github/workflows/pull-request-jobs.yml | 8 +- .../ui/AuthComposeInstrumentedTest.kt | 29 +++---- .../test/showcase/ui/ComposeLoginRobot.kt | 6 +- .../compose/idle/ComposeIdlingDisposable.kt | 22 ++++++ .../ComposeNetworkSynchronizationTestRule.kt | 50 ++++++++++++ .../EspressoToComposeIdlingResourceAdapter.kt | 7 ++ codekata/compose.instructionset.md | 43 ++++++----- .../ui/compose/AuthComposeInstrumentedTest.kt | 51 +++++++------ .../hilt/ui/compose/ComposeLoginRobot.kt | 6 +- .../compose/idle/ComposeIdlingDisposable.kt | 22 ++++++ .../compose/idle/ComposeNetworkSyncHelper.kt | 29 +++++++ .../EspressoToComposeIdlingResourceAdapter.kt | 7 ++ hilt/hilt-network-di-test-util/build.gradle | 1 + .../testutil/NetworkSynchronization.kt | 1 + .../network/testutil/OkHttp3IdlingResource.kt | 76 ------------------- .../idlingresources/OkHttp3IdlingResource.kt | 28 ++++--- 16 files changed, 227 insertions(+), 159 deletions(-) create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt create mode 100644 hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt delete mode 100644 hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt diff --git a/.github/workflows/pull-request-jobs.yml b/.github/workflows/pull-request-jobs.yml index 6b087d8..920f129 100644 --- a/.github/workflows/pull-request-jobs.yml +++ b/.github/workflows/pull-request-jobs.yml @@ -75,7 +75,9 @@ jobs: if: always() with: name: JVM Test Results - path: ./**/build/reports/tests/**/*.html + path: | + ./**/build/reports/tests/**/*.html + ./**/**/build/reports/tests/**/*.html retention-days: 1 run-tests-on-emulator: @@ -126,7 +128,9 @@ jobs: if: always() with: name: Emulator-Test-Results-${{ matrix.api-level }} - path: ./**/build/reports/androidTests/**/*.html + path: | + ./**/build/reports/androidTests/**/*.html + ./**/**/build/reports/androidTests/**/*.html retention-days: 1 - name: Upload Test Screenshots uses: actions/upload-artifact@v2 diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index ac8f1b5..608b74a 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -1,21 +1,18 @@ package org.fnives.test.showcase.ui -import androidx.compose.ui.test.MainTestClock import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.espresso.Espresso -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule -import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor import org.fnives.test.showcase.compose.screen.AppNavigation import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.DatabaseDispatcherTestRule +import org.fnives.test.showcase.ui.compose.idle.ComposeNetworkSynchronizationTestRule import org.junit.Before import org.junit.Rule import org.junit.Test @@ -29,7 +26,9 @@ class AuthComposeInstrumentedTest : KoinTest { private val composeTestRule = createComposeRule() private val stateRestorationTester = StateRestorationTester(composeTestRule) - private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule( + networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule) + ) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val dispatcherTestRule = DatabaseDispatcherTestRule() private lateinit var robot: ComposeLoginRobot @@ -72,7 +71,7 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() navigationRobot.assertHomeScreen() } @@ -86,7 +85,7 @@ class AuthComposeInstrumentedTest : KoinTest { .assertUsername("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -103,7 +102,7 @@ class AuthComposeInstrumentedTest : KoinTest { .assertPassword("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -129,7 +128,7 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -155,7 +154,7 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -181,15 +180,5 @@ class AuthComposeInstrumentedTest : KoinTest { companion object { private const val SPLASH_DELAY = 600L - - // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 - /** - * Await the idling resource on a different thread while looping main. - */ - fun MainTestClock.awaitIdlingResources() { - Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L)) - - advanceTimeByFrame() - } } } diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 1791ed1..5c0d410 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -1,10 +1,10 @@ package org.fnives.test.showcase.ui import android.content.Context +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -13,8 +13,8 @@ import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt new file mode 100644 index 0000000..1f3efc0 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt @@ -0,0 +1,22 @@ +package org.fnives.test.showcase.ui.compose.idle + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable + +class ComposeIdlingDisposable( + private val idlingResource: IdlingResource, + private val testRule: ComposeTestRule, +) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + testRule.registerIdlingResource(idlingResource) + } + + override fun dispose() { + isDisposed = true + testRule.unregisterIdlingResource(idlingResource) + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt new file mode 100644 index 0000000..7f68107 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt @@ -0,0 +1,50 @@ +package org.fnives.test.showcase.ui.compose.idle + +import android.util.Log +import androidx.annotation.CheckResult +import androidx.compose.ui.test.junit4.ComposeTestRule +import okhttp3.OkHttpClient +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource +import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.koin.test.KoinTest + +class ComposeNetworkSynchronizationTestRule(private val composeTestRule: ComposeTestRule) : TestRule, KoinTest { + + private var disposable: Disposable? = null + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + disposable = registerIdlingResources() + try { + base.evaluate() + } finally { + dispose() + } + } + } + } + + fun dispose() { + if (disposable == null) { + Log.w("ComposeNetworkSynchronizationTestRule", "Was disposed, but registerIdlingResources was not called!") + } + disposable?.dispose() + } + + @CheckResult + private fun registerIdlingResources(): Disposable = getOkHttpClients() + .associateBy(keySelector = { it.toString() }) + .map { (key, client) -> OkHttp3IdlingResource.create(key, client) } + .map(::EspressoToComposeIdlingResourceAdapter) + .map { ComposeIdlingDisposable(it, composeTestRule) } + .let(::CompositeDisposable) + + private fun getOkHttpClients(): List = + NetworkTestConfigurationHelper.getOkHttpClients() +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt new file mode 100644 index 0000000..866595b --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.ui.compose.idle + +import androidx.test.espresso.IdlingResource + +class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource { + override val isIdleNow: Boolean get() = idlingResource.isIdleNow +} diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index 5b461ba..a5129b0 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -22,8 +22,8 @@ Here is a list of actions we want to do: ```kotlin class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) @@ -60,11 +60,13 @@ class ComposeLoginRobot( } ``` -While in the View system we're using Espresso to interact with views, -in Compose we need a reference to the `ComposeTestRule` that contains our UI, +While in the View system we're using Espresso to interact with views, +in Compose we need a reference to the `SemanticsNodeInteractionsProvider` that contains our UI, which we will pass as a constructor parameter to the robot. -To create a `ComposeTestRule` you simply need to: +> SemanticsNodeInteractionsProvider gives access to `onNode` actions. ComposeTestRule extends it. + +To create a `ComposeTestRule` you simply need to: ```kotlin @get:Rule @@ -80,12 +82,12 @@ To add a tag to a composable use the `testTag` modifier in your UI, for example: Modifier.testTag(AuthScreenTag.UsernameInput) ``` -Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`. +Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`. For a list of finder, actions and assertions see the docs: https://developer.android.com/jetpack/compose/testing#testing-apis ##### Next up, we need to verify if we navigated: -If the navigation is also in compose we don't have an intent to check if we navigated. +If the navigation is also in compose we don't have an intent to check if we navigated. So instead, we're simply searching for regular composables that represent our destinations. This means that we could write a robot for our navigation which will simply check whether the root Composable for destination exists: @@ -102,7 +104,7 @@ This means that we could write a robot for our navigation which will simply chec ##### What about the Snackbar -Since everything in Compose is a composable, our Snackbar doesn't have anything special. +Since everything in Compose is a composable, our Snackbar doesn't have anything special. Put a tag on it and use the same finders and assertions. #### Test class setup @@ -111,7 +113,7 @@ The setup is the mostly the same as for View so for the sake of simplicity let's ##### Initializing the UI -We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. +We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. If you need a specific activity, use `createAndroidComposeRule()`. ```kotlin @@ -152,11 +154,15 @@ fun setup() { Network synchronization and mocking is the same as for View. ```kotlin -private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() +private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule( + networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule) +) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup ``` -Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need. +> ComposeNetworkSynchronizationTestRule is an equivalent to NetworkSynchronizationTestRule just registering the IdlingResource to ComposeTestRule instead of Espresso + +Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need. ```kotlin private val dispatcherTestRule = DatabaseDispatcherTestRule() @@ -214,12 +220,13 @@ composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again Lastly we check the navigation was correct, meaning we should be on the home screen: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() // wait for login network call idling resource +composeTestRule.waitForIdle() // wait for login network call idling resource navigationRobot.assertHomeScreen() ``` -> `awaitIdlingResources` is an extension function to await all idling resources. -> Note: Considering what the docs say this shouldn't be necessarily if the idling resources are setup in Espresso, since the compose test rule is aware of espresso and it waits for idle before every finder. In practice it only works with the line above. Could be a bug somewhere. +> waitForIdle is necessary to wait for the Coroutine then the Network Call to finish. The Network call is running on OkHttps's own thread, so we use IdlingResources to synchronize with it. This is done in the ComposeNetworkSynchronizationTestRule. +> waitForIdle blocks the current thread while the Resources are busy. There is an alternative awaitIdle() which can be useful in runTest suspendable tests, feel free to look inside the Interface of ComposeTestRule. +> Basically since we have OkHttpIdlingResource as an EspressoIdlingResource we adapt that to Compose's IdlingResource class and register it with the ComposeTestRule and unregister it at the end. ### 2. `emptyPasswordShowsProperErrorMessage` @@ -240,7 +247,7 @@ robot.setUsername("banan") Finally we let coroutines go and verify the error is shown and we have not navigated: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -260,7 +267,7 @@ robot .assertPassword("banan") .clickOnLogin() -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -293,7 +300,7 @@ composeTestRule.mainClock.autoAdvance = true Now at the end verify the error is shown properly: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -323,7 +330,7 @@ composeTestRule.mainClock.advanceTimeByFrame() robot.assertLoading() composeTestRule.mainClock.autoAdvance = true -composeTestRule.mainClock.awaitIdlingResources() +composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt index 6ece289..211276e 100644 --- a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -1,31 +1,31 @@ package org.fnives.test.showcase.hilt.ui.compose -import androidx.compose.ui.test.MainTestClock import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.espresso.Espresso -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule -import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor import org.fnives.test.showcase.hilt.R import org.fnives.test.showcase.hilt.compose.screen.AppNavigation import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage import org.fnives.test.showcase.hilt.di.TestUserDataLocalStorageModule import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule import org.fnives.test.showcase.hilt.test.shared.testutils.idling.DatabaseDispatcherTestRule -import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.compose.idle.ComposeNetworkSyncHelper import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith +import javax.inject.Inject @HiltAndroidTest @RunWith(AndroidJUnit4::class) -class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { +class AuthComposeInstrumentedTest { private val composeTestRule = createComposeRule() private val stateRestorationTester = StateRestorationTester(composeTestRule) @@ -36,6 +36,12 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { private lateinit var robot: ComposeLoginRobot private lateinit var navigationRobot: ComposeNavigationRobot + @Inject + lateinit var composeNetworkSyncHelper: ComposeNetworkSyncHelper + + @get:Rule + val hiltRule = HiltAndroidRule(this) + @Rule @JvmField val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) @@ -44,16 +50,22 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { .around(composeTestRule) .around(ScreenshotRule("test-showcase-compose")) - override fun setupBeforeInjection() { + @Before + fun setup() { TestUserDataLocalStorageModule.replacement = FakeUserDataLocalStorage() - } + hiltRule.inject() - override fun setupAfterInjection() { stateRestorationTester.setContent { AppNavigation() } robot = ComposeLoginRobot(composeTestRule) navigationRobot = ComposeNavigationRobot(composeTestRule) + composeNetworkSyncHelper.setup(composeTestRule) + } + + @After + fun tearDown() { + composeNetworkSyncHelper.tearDown() } /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ @@ -76,11 +88,10 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() navigationRobot.assertHomeScreen() } - /** GIVEN empty password and username WHEN signIn THEN error password is shown */ @Test fun emptyPasswordShowsProperErrorMessage() { composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) @@ -90,7 +101,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { .assertUsername("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -107,7 +118,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { .assertPassword("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -133,7 +144,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -159,7 +170,7 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() + composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -185,15 +196,5 @@ class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { companion object { private const val SPLASH_DELAY = 600L - - // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 - /** - * Await the idling resource on a different thread while looping main. - */ - fun MainTestClock.awaitIdlingResources() { - Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L)) - - advanceTimeByFrame() - } } } diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt index 913c350..a653854 100644 --- a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt @@ -1,10 +1,10 @@ package org.fnives.test.showcase.hilt.ui.compose import android.content.Context +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -13,8 +13,8 @@ import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt new file mode 100644 index 0000000..e0e0936 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt @@ -0,0 +1,22 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable + +class ComposeIdlingDisposable( + private val idlingResource: IdlingResource, + private val testRule: ComposeTestRule, +) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + testRule.registerIdlingResource(idlingResource) + } + + override fun dispose() { + isDisposed = true + testRule.unregisterIdlingResource(idlingResource) + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt new file mode 100644 index 0000000..a44e1ef --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import android.util.Log +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable +import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization +import javax.inject.Inject + +class ComposeNetworkSyncHelper @Inject constructor( + private val networkSynchronization: NetworkSynchronization, +) { + + private var disposable: Disposable? = null + + fun setup(composeTestRule: ComposeTestRule) { + disposable = networkSynchronization.networkIdlingResources() + .map(::EspressoToComposeIdlingResourceAdapter) + .map { ComposeIdlingDisposable(it, composeTestRule) } + .let(::CompositeDisposable) + } + + fun tearDown() { + if (disposable == null) { + Log.w("ComposeNetworkSyncHelper", "tearDown called, but setup wasn't!") + } + disposable?.dispose() + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt new file mode 100644 index 0000000..a76b2c4 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import androidx.test.espresso.IdlingResource + +class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource { + override val isIdleNow: Boolean get() = idlingResource.isIdleNow +} diff --git a/hilt/hilt-network-di-test-util/build.gradle b/hilt/hilt-network-di-test-util/build.gradle index 5db617c..55921d0 100644 --- a/hilt/hilt-network-di-test-util/build.gradle +++ b/hilt/hilt-network-di-test-util/build.gradle @@ -40,4 +40,5 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation project(':mockserver') implementation "androidx.test.espresso:espresso-core:$espresso_version" + implementation project(":test-util-android") } \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt index ffae218..d531f81 100644 --- a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt @@ -3,6 +3,7 @@ package org.fnives.test.showcase.hilt.network.testutil import androidx.annotation.CheckResult import androidx.test.espresso.IdlingResource import okhttp3.OkHttpClient +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier import org.fnives.test.showcase.hilt.network.di.SessionQualifier import javax.inject.Inject diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt deleted file mode 100644 index 69423f2..0000000 --- a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.fnives.test.showcase.hilt.network.testutil - -import androidx.annotation.CheckResult -import androidx.annotation.NonNull -import androidx.test.espresso.IdlingResource -import okhttp3.Dispatcher -import okhttp3.OkHttpClient - -/** - * AndroidX version of Jake Wharton's OkHttp3IdlingResource. - * - * Reference: https://github.com/JakeWharton/okhttp-idling-resource/blob/master/src/main/java/com/jakewharton/espresso/OkHttp3IdlingResource.java - */ -class OkHttp3IdlingResource private constructor( - private val name: String, - private val dispatcher: Dispatcher -) : IdlingResource { - @Volatile - var callback: IdlingResource.ResourceCallback? = null - private var isIdleCallbackWasCalled: Boolean = true - - init { - val currentCallback = dispatcher.idleCallback - dispatcher.idleCallback = Runnable { - sleepForDispatcherDefaultCallInRetrofitErrorState() - callback?.onTransitionToIdle() - currentCallback?.run() - isIdleCallbackWasCalled = true - } - } - - override fun getName(): String = name - - override fun isIdleNow(): Boolean { - val isIdle = dispatcher.runningCallsCount() == 0 - if (isIdle) { - // sometime the callback is just not properly called it seems, or maybe sync error. - // if it isn't called Espresso crashes, so we add this here. - callback?.onTransitionToIdle() - } - return isIdle - } - - override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { - this.callback = callback - } - - companion object { - /** - * Create a new [IdlingResource] from `client` as `name`. You must register - * this instance using `Espresso.registerIdlingResources`. - */ - @CheckResult - @NonNull - fun create(@NonNull name: String?, @NonNull client: OkHttpClient?): OkHttp3IdlingResource { - if (name == null) throw NullPointerException("name == null") - if (client == null) throw NullPointerException("client == null") - return OkHttp3IdlingResource(name, client.dispatcher) - } - - /** - * This is required, because in case of Errors Retrofit uses Dispatcher.Default to suspendThrow - * see: retrofit2.KotlinExtensions.kt Exception.suspendAndThrow - * Relevant code issue: https://github.com/square/retrofit/blob/6cd6f7d8287f73909614cb7300fcde05f5719750/retrofit/src/main/java/retrofit2/KotlinExtensions.kt#L121 - * This is the current suggested approach to their problem with Unchecked Exceptions - * - * Sadly Dispatcher.Default cannot be replaced yet, so we can't swap it out in tests: - * https://github.com/Kotlin/kotlinx.coroutines/issues/1365 - * - * This brings us to this sleep for now. - */ - fun sleepForDispatcherDefaultCallInRetrofitErrorState() { - Thread.sleep(200L) - } - } -} diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt index 9a48319..258067b 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt @@ -17,29 +17,33 @@ class OkHttp3IdlingResource private constructor( ) : IdlingResource { @Volatile var callback: IdlingResource.ResourceCallback? = null + @Volatile private var isIdleCallbackWasCalled: Boolean = true + private val idleSync = Any() init { val currentCallback = dispatcher.idleCallback dispatcher.idleCallback = Runnable { - sleepForDispatcherDefaultCallInRetrofitErrorState() - callback?.onTransitionToIdle() - currentCallback?.run() - isIdleCallbackWasCalled = true + synchronized(idleSync) { + sleepForDispatcherDefaultCallInRetrofitErrorState() + callback?.onTransitionToIdle() + currentCallback?.run() + isIdleCallbackWasCalled = true + } } } override fun getName(): String = name - override fun isIdleNow(): Boolean { - val isIdle = dispatcher.runningCallsCount() == 0 - if (isIdle) { - // sometime the callback is just not properly called it seems, or maybe sync error. - // if it isn't called Espresso crashes, so we add this here. - callback?.onTransitionToIdle() + override fun isIdleNow(): Boolean = + synchronized(idleSync) { + val isIdle = dispatcher.runningCallsCount() == 0 + if (!isIdle) { + isIdleCallbackWasCalled = false + } + + return@synchronized isIdle && isIdleCallbackWasCalled } - return isIdle - } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { this.callback = callback From a4b1e50f0f54c54600c188c3cd090cd2637889ac Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Wed, 28 Sep 2022 19:01:38 +0300 Subject: [PATCH 4/4] Issue#41 Remove waitForIdle since onNode already calls it --- .../test/showcase/ui/AuthComposeInstrumentedTest.kt | 5 ----- codekata/compose.instructionset.md | 8 ++------ .../hilt/ui/compose/AuthComposeInstrumentedTest.kt | 5 ----- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index 608b74a..97287e5 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -71,7 +71,6 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.waitForIdle() navigationRobot.assertHomeScreen() } @@ -85,7 +84,6 @@ class AuthComposeInstrumentedTest : KoinTest { .assertUsername("banan") .clickOnLogin() - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -102,7 +100,6 @@ class AuthComposeInstrumentedTest : KoinTest { .assertPassword("banan") .clickOnLogin() - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -128,7 +125,6 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -154,7 +150,6 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() diff --git a/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index a5129b0..8052c4f 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -220,12 +220,12 @@ composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again Lastly we check the navigation was correct, meaning we should be on the home screen: ```kotlin -composeTestRule.waitForIdle() // wait for login network call idling resource navigationRobot.assertHomeScreen() ``` -> waitForIdle is necessary to wait for the Coroutine then the Network Call to finish. The Network call is running on OkHttps's own thread, so we use IdlingResources to synchronize with it. This is done in the ComposeNetworkSynchronizationTestRule. +> Note: Any node interactions call waitForIdle which waits for the Coroutine then the Network Call to finish. The Network call is running on OkHttps's own thread, so we use IdlingResources to synchronize with it. This is done in the ComposeNetworkSynchronizationTestRule. > waitForIdle blocks the current thread while the Resources are busy. There is an alternative awaitIdle() which can be useful in runTest suspendable tests, feel free to look inside the Interface of ComposeTestRule. +> If you don't interact with a node but want to synchronize, then you will need waitForIdle. For example to verify something was called or written into like FakeLocalStorage in this example > Basically since we have OkHttpIdlingResource as an EspressoIdlingResource we adapt that to Compose's IdlingResource class and register it with the ComposeTestRule and unregister it at the end. ### 2. `emptyPasswordShowsProperErrorMessage` @@ -247,7 +247,6 @@ robot.setUsername("banan") Finally we let coroutines go and verify the error is shown and we have not navigated: ```kotlin -composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -267,7 +266,6 @@ robot .assertPassword("banan") .clickOnLogin() -composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -300,7 +298,6 @@ composeTestRule.mainClock.autoAdvance = true Now at the end verify the error is shown properly: ```kotlin -composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -330,7 +327,6 @@ composeTestRule.mainClock.advanceTimeByFrame() robot.assertLoading() composeTestRule.mainClock.autoAdvance = true -composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt index 211276e..ec296b0 100644 --- a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -88,7 +88,6 @@ class AuthComposeInstrumentedTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.waitForIdle() navigationRobot.assertHomeScreen() } @@ -101,7 +100,6 @@ class AuthComposeInstrumentedTest { .assertUsername("banan") .clickOnLogin() - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -118,7 +116,6 @@ class AuthComposeInstrumentedTest { .assertPassword("banan") .clickOnLogin() - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -144,7 +141,6 @@ class AuthComposeInstrumentedTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -170,7 +166,6 @@ class AuthComposeInstrumentedTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.waitForIdle() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen()