From 52a99a82fc77dfe462c549bc28abd089913c6ddd Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Tue, 27 Sep 2022 17:16:05 +0300 Subject: [PATCH] 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 zcmV-x43zVUP)o{V*PtcDPCy$8xI?*D0 z#|{f$+an_aNTI&+-{A-#XX!H%w3M$uv(Xdr*efa4adjyXv31G2tI=y<={XN_e76TFk8md^tmU8aj|f4V@aGui*vl#5G2*0(*t(=P zN)%#h)g$_gWSFxd9sDhdz(Y_59;B>|qJkoPJgN8>|6H^efByFk&gcAj+~uUG*xKZJ z1=8ZAt{8Emv@gs*yjeQgH@C}Es}x0WD6e-XO;OTW5p>RvEt+!8F6uv zH0DyGggN#Qb#;h3p8~Hg-7WPsIU;c-Zk5XW2L30t%T|D&RyIAsAfQmk6 zx=PYc@Ev|cR*c?5jzP%l*|6wb|3x6?zTh79Q8Ez@smu!mDX#z{l*g z+;iB%b|nRuC^e2_4>LAzov4e6i{z}sbQ_G~j5qg!mtKh~NezJ6cs+S1(>j$&%Ofo3 zI0UVzR}esL)Wurq7`TuFamcX;E3t|q#R-F{Z|j+8aD9G$@dq$%N{)(U<~meDX8e!f zty8W-xF>k)AA^|-vT4iRA|w2ccq5#+UImHYX261qObB_u0fLq{C}jXqigN?jLW31a z7%I=19RUH1qOfBt!};h_0Wc>!9{jEIRLCHLM}7w1T(}Kk2W{8jKr%DQ(6XJ-)@Ag`cjya zS;>&^O#ojDE+qFfk<|jW)zDC8f-tf57-!<--}cg~x4cwKKYHgU!2|yJ63)NakFb7Z0$9ewV?%e zmfeK9mfH| zz)0ww#SjqM-ns7icEHne5q5 zDGTJ67Xl-p&*z#7K=KjpRq*}AJJ8vwN={SYZ|i?~1j{n6LrA=40`TZUa=xUTyIZM= zP^fQg@7<`Wfarkp@XNI}6+%yn8@JnGb@mO71&Byq(`XWAC=+08?Ksld-sd+>1k5x) z4Xr=@3DT#Cd{6lOZU*32X+xHn%LEu$i7aXH-swxgB*RbObm6ZGFTQvCA=Fg1 zz{5WutB_KMw!b9sP4h$e^Do8Pss6SNUVHCap90)S(Oqk@+D0Z|=vb$5ng~dYxC|Zb zoeD4AwEQ&K2~wD2M|sA%z4b`tMb8R!5#<*sfJ*>5>;NjDI;ikJ$RTE%aKRqP#l!Fkz&eWcNOJo zz*6SFQgC%J)Y5S=jlP17jHw9ok;_@bpixG4UJYCpI{9M8OpG1z4!CL;0)FOjBpD3x z-pD%*$ppW^QxNoS_gY^QumX>Q+ zS2TjQRIz+5$YAdW=4OjH5he8Ddle$@sFx1jZeL)OIm=M!)vjzMUIb=>x04guLksmIa2~b?Cj9$61h4Kt z`QSCzVA9-La5pZb-AA7ML28{mn(aO9FOWH*$W27L|BzJ&71+`7Q)btAXHEeYZyyyw?PiBfbI;(<3zM2YMne#cMB;_X>~;Ya0zd z?s5a`0aIl$)XKSx%Y@8M&yDwoF}~s87j+zh*Y%$~+ALt&=iq9P4~pHU+ctR3mb`zQ zau~~UQRjPggPo!EC@krIA&>5v*~pLgmk4Jibxm1$S%sJ;nD)*O;A2M%RSTouO&)ta zTd;Y|9Kc213OVpIZvO{Y)D-3h=C+#*ZM`3xI8K1TCDmQQ>zgICO?D|0REOZTO%V9X z1#mMd1ZRQrBy|vpqX7K!_IRAu(#`lzeD{-+?;dEU0TiZ#hL4&%%vvP2niL1qR((Tv zRsUm!tZ$Z5s1<`BZ2kNy!9@=vy&r*nU5dnUhDG9dT}aZzBgl+5@Lhxm2lU@I>W6>` z@qq-t1lSH6WBa~s@S29{tG{OmQXw3gr6KDTF=gHrsoSU`sUyi|K-cm}91E4R&OSPi zeAHm6wK$PJ+Ya9$BnbohQ?Qy3cIl0ldV%QbI@|azU*osvWYyFam+u9wy-H_ZwxC1e zTOjz|CYTgl2X2FLVRk^aw!tG6fs3+xbWK%dp%uACmke`eo~covfBGTdgbbn>PVwSM z+Zn;4*=g&178Dl*#Ga{{w6MC-%dWiDRXhI{_4X~gc50@mBKj^luYcpKb$l`EnB5a5n2#bpYw<+6Lhgin?ay;jDZ?turuUj9lfAl4v-1fbD z-ur&fJNNeXJ%Gp-#@n-Cja~=GW3+@d%tHFkd0xV03rf$8jux;jG=X!m8SM9K0biRn zOr>o`@f3K61ssJb;21~&=a9BI1}xBA$eI+G@E){)y@&!aoG?LMF-eP>)_^=%Oi&YN z2FG6fPLY9dy#o)zjujCD5d#qe5d;3@W}L?Np8GLqVXLAX_`!>( zF#F|BZ4GQ(`W3b{_u(`5ei4)bj_YV0z>Q13)V_rFy?F);-~AO=&vS~*u8(WQEv7bn zJ*@-ZO8*`+QoqIL4adcPKK^Ko6U%p<6LDcN2A*Enif#5j{Aha@wp9F%-DgH{_)j-p zbC07WN$9>hj)VPfvENqIgL_^(jqNS{B2IGyoYAe1wd1+2%R;PDxIf2nyk-VgM>{cl zYbOp3xPuZe!*LwX=eg+yICD9#bmBi(ug^-n49D|%rn?iW8Q8Yw1YRHWObQtv_n^n) zQ6!rCaew(cpJ&^e_M2zm$@#~y_xzPf0ar$^;rp*1!;;(%KVehB$BT1*zz;LN#=j{} z-rswEl+N3tz5%|~2eN+g6V=;2jHydL#TV99`-z$gKECCNdfc$I9542a_#G?AI*BP! zc6AM;-TgUs|LpotN&I$p5c@B<6qQha?7Frlt^9t#GjkdmQ} z3>*#Z519pQLZF)BjWiCDpi<&eqBitvA+L#opv4>hSA3Pg1V?CJ6&a}EKI*JS*#rwz zCjL-R;=P$aLM2qoeaOIeb^Z^q3C?V&#dGG3G>CZ&(4A1vfY(H$sOwOQ=gb?Sh74Hw znEFp8*aQolOU?U==gb?SPBIGv)yfUb<>4-u6N^+XzH(aeoOzQl(7<;>*~48u)42{xCD3ubSnA*cc ziTKVKpo}P&_(zK8d7dhHw&4NGHP2}6-O+@ zbNNIoeN#zJ;eiE^_|zg;pB@KmL%`z-xe>DnQqvcJK8>{~$bO&+-L3Q?mYA^FPji?gG&1 z7Y0p-l3)|mh4Qsp<9V3 z!L|{}V4jr>m6?f9u_qbI>j`Iwd?SND^LO%EWhURtW4c@jyI#A>$xtzqP(^4apr2?~ z#?Fj~s=n^#2;fV`x%xQ?%rDX^9@7${m9`~9nN<(?US~1pV{B8+3F(ah<%c#gC&0F3 zny}VEy4*^k(I(&h^ML!Y<+VJAySY0}o?>?%O=8hZZs|bSctE*DBZTeF>$C{%KyvwN z&17xuL!k4JHp|-_e{ap~ga}79Aj1Xfs&o7Xnz$=uj!17LL1`gT2=Y^eBYDVBhO5!8 zfOfIQ!{#higs#L^YdpF0HPu*GlGlnL?-R<<1jX~U+oW_ZTv-MHq1-*94xxx?h{=~0Qo*|F**d=G?m%f2zHJQ-AdHS6^N-H-8q_9vxgI?p^%+QQWuvw6LMS zcQR8lmo&%0Cr10`<|v&-vmINy{*|2f=mGro@I@?Ja}v+~`yX1ZR~LZPF?-Gvp~)GNRQg(q%@X9m~>jDh9DNWf`50;WY&W5Zup&}y*;OaR+ro6{n8 z=@hW>ObpD6unQOkD3ay5t|H$(t@*#oFB`@#>Wp-GnbSUc*CsPT}Py z=P}hKr^Fn-1SGlJL65j1O2v(a_LH_F#Rx2iOIsE^g3+2 zZt*FA@5x_r1Roojhg-6LYIptTLuc{6n>M)>;RvDPvr#ZN(k_4#A_+EoP2xAE9mM8K zt?edkZE3@E4b8e%U+aZ)4Ohfi*K3y>TkzG#s%XP1YA3=WApgW5n0%|Yo9jvK>OM~b z?g}iZSrv0_N=0N<_i@1Ud^`a@WjFFjU~A@O(@OUnTH>KssSHJ{X=s z$RuPbSVPFhhr-t?STFE!cn)C$;VXej;kkr7VRuq^0ZtYuk{DKugS|bK>lp#FpBN9D zT75m8U=d705)X9PF!?+RokAM7wskN(J)OCkAS+bQqs9I)koFPRTp3E98$1ptCJ|`z zl(beEIvXf)=PzMSJ|8VQN|SKWP$PU+zywKz)w_r?K|&JmeAp=Dt^D0cc(<6jNKU$c zwGsKit{P4;UZHq`P-gKhA~0dY-^IehO5$**7P5H~ z!4r3^+T_|~!WN2a%F0JE2W7|g|D_CM5fRabx0lc!(V{-y@GPWUkdnmpZc=$noCWZc zx0Yzb>q~jgPLX#GCu0we!=doxjCjbXlitt@asK5|dLmB{cSoMUb=}Iud52}<{5|Dd z-4J;ESsC*@=k(x!{me?IHWtB80%D^LZ!R1Q>yC48q6iU4RDM~JD7DfiWTg^FSVved zK)t+GNmM0ta%dm<;&`NC?jo`s$8*TJZot1GC^eWvYH%p@Plz(iO{Mx@Lo3+CUXeR^ zg+`Qk1fs6G2pfJU5!{qDQBPGf+%PAVZLv*Vbv_W@P$ub37(^I35bm8CZJ7TaHG{=t zV0A5pOFgY{1Cc+^pk!{Kow}Z`*N%Yqi-#Ltd~X0uoJwP36dS#z&F=Z12^P~&A;O6l z8wvwWVKDmsTVUd327eBPn@!>Yh`eWzGA6Ve{@J6wN4QcLp`?eBC#rY&{~vBr&{z2` 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'