From 90a9426b7da0282046213295bfb806607a36e523 Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Wed, 7 Apr 2021 21:12:10 +0300 Subject: [PATCH] initial commit --- .gitignore | 76 +++ app/.gitignore | 1 + app/build.gradle | 144 +++++ app/proguard-rules.pro | 21 + .../AndroidTestLoginRobotConfiguration.kt | 5 + .../AndroidTestMainDispatcherTestRule.kt | 51 ++ .../AndroidTestServerTypeConfiguration.kt | 29 + ...AndroidTestSnackbarVerificationTestRule.kt | 44 ++ .../SpecificTestConfigurationsFactory.kt | 15 + app/src/main/AndroidManifest.xml | 28 + app/src/main/ic_launcher-playstore.png | Bin 0 -> 14084 bytes .../test/showcase/TestShowcaseApplication.kt | 18 + .../test/showcase/di/BaseUrlProvider.kt | 9 + .../test/showcase/di/createAppModules.kt | 55 ++ .../session/SessionExpirationListenerImpl.kt | 21 + .../test/showcase/storage/LocalDatabase.kt | 12 + .../storage/SharedPreferencesManagerImpl.kt | 58 ++ .../database/DatabaseInitialization.kt | 13 + .../FavouriteContentLocalStorageImpl.kt | 19 + .../storage/favourite/FavouriteDao.kt | 21 + .../storage/favourite/FavouriteEntity.kt | 7 + .../test/showcase/ui/auth/AuthActivity.kt | 55 ++ .../test/showcase/ui/auth/AuthViewModel.kt | 66 ++ .../ui/auth/SetTextIfNotSameObserver.kt | 13 + .../ui/home/FavouriteContentAdapter.kt | 51 ++ .../test/showcase/ui/home/MainActivity.kt | 68 ++ .../test/showcase/ui/home/MainViewModel.kt | 81 +++ .../fnives/test/showcase/ui/shared/Event.kt | 11 + .../ui/shared/VerticalSpaceItemDecoration.kt | 13 + .../showcase/ui/shared/ViewBindingAdapter.kt | 6 + .../test/showcase/ui/shared/ViewExtension.kt | 24 + .../test/showcase/ui/splash/SplashActivity.kt | 27 + .../showcase/ui/splash/SplashViewModel.kt | 28 + .../drawable-v24/ic_launcher_foreground.xml | 31 + app/src/main/res/drawable/favorite_24.xml | 10 + .../main/res/drawable/favorite_border_24.xml | 10 + .../res/drawable/ic_launcher_background.xml | 10 + app/src/main/res/drawable/logout_24.xml | 11 + .../res/layout/activity_authentication.xml | 98 +++ app/src/main/res/layout/activity_main.xml | 54 ++ app/src/main/res/layout/activity_splash.xml | 16 + .../res/layout/item_favourite_content.xml | 56 ++ app/src/main/res/menu/main.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1739 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3463 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1331 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2208 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2491 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4988 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3620 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 7728 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 5121 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 11062 bytes app/src/main/res/values-night/themes.xml | 16 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/dimens.xml | 11 + app/src/main/res/values/strings.xml | 13 + app/src/main/res/values/themes.xml | 16 + .../FavouriteContentLocalStorageImplTest.kt | 91 +++ .../RobolectricLoginRobotConfiguration.kt | 5 + .../RobolectricServerTypeConfiguration.kt | 11 + ...RobolectricSnackbarVerificationTestRule.kt | 19 + .../SpecificTestConfigurationsFactory.kt | 15 + .../TestCoroutineMainDispatcherTestRule.kt | 48 ++ .../testutils/shadow/ShadowSnackbar.kt | 100 +++ .../shadow/ShadowSnackbarResetTestRule.kt | 20 + .../test/showcase/di/BaseUrlProvider.kt | 9 + .../database/DatabaseInitialization.kt | 21 + .../MockServerScenarioSetupTestRule.kt | 36 ++ .../ReloadKoinModulesIfNecessaryTestRule.kt | 33 + .../configuration/LoginRobotConfiguration.kt | 6 + .../configuration/MainDispatcherTestRule.kt | 14 + .../configuration/ServerTypeConfiguration.kt | 12 + .../configuration/SnackBarTestRule.kt | 5 + .../SnackbarVerificationTestRule.kt | 11 + .../TestConfigurationsFactory.kt | 18 + .../testutils/doBlockinglyOnMainThread.kt | 19 + .../testutils/idling/CompositeDisposable.kt | 19 + .../showcase/testutils/idling/Disposable.kt | 6 + .../idling/IdlingResourceDisposable.kt | 19 + .../idling/NetworkSynchronization.kt | 33 + .../testutils/idling/awaitIdlingResources.kt | 68 ++ .../test/showcase/testutils/robot/Robot.kt | 8 + .../showcase/testutils/robot/RobotTestRule.kt | 20 + .../statesetup/SetupLoggedInState.kt | 31 + .../viewactions/LoopMainThreadFor.kt | 27 + .../testutils/viewactions/PullToRefresh.kt | 44 ++ .../SwipeRefreshLayoutExtension.kt | 5 + .../testutils/viewactions/WithDrawable.kt | 37 ++ .../testutils/viewactions/notIntended.kt | 17 + .../fnives/test/showcase/ui/home/HomeRobot.kt | 95 +++ .../test/showcase/ui/home/MainActivityTest.kt | 239 ++++++++ .../showcase/ui/login/AuthActivityTest.kt | 159 +++++ .../test/showcase/ui/login/LoginRobot.kt | 100 +++ .../showcase/ui/splash/SplashActivityTest.kt | 89 +++ .../test/showcase/ui/splash/SplashRobot.kt | 40 ++ .../org/fnives/test/showcase/di/DITest.kt | 63 ++ .../testutils/InstantExecutorExtension.kt | 25 + .../showcase/testutils/TestMainDispatcher.kt | 31 + .../showcase/ui/auth/AuthViewModelTest.kt | 190 ++++++ .../showcase/ui/home/MainViewModelTest.kt | 243 ++++++++ .../test/showcase/ui/shared/EventTest.kt | 48 ++ .../showcase/ui/splash/SplashViewModelTest.kt | 55 ++ .../org.mockito.plugins.MockMaker | 1 + app/src/test/resources/robolectric.properties | 2 + build.gradle | 63 ++ core/.gitignore | 1 + core/build.gradle | 32 + .../content/AddContentToFavouriteUseCase.kt | 12 + .../core/content/ContentRepository.kt | 35 ++ .../core/content/FetchContentUseCase.kt | 6 + .../core/content/GetAllContentUseCase.kt | 33 + .../RemoveContentFromFavouritesUseCase.kt | 13 + .../test/showcase/core/di/createCoreModule.kt | 60 ++ .../core/login/IsUserLoggedInUseCase.kt | 8 + .../test/showcase/core/login/LoginUseCase.kt | 30 + .../test/showcase/core/login/LogoutUseCase.kt | 13 + .../core/session/SessionExpirationAdapter.kt | 11 + .../core/session/SessionExpirationListener.kt | 5 + .../test/showcase/core/shared/AnswerUtils.kt | 25 + .../test/showcase/core/shared/Optional.kt | 3 + .../core/shared/UnexpectedException.kt | 11 + .../NetworkSessionLocalStorageAdapter.kt | 15 + .../core/storage/UserDataLocalStorage.kt | 7 + .../content/FavouriteContentLocalStorage.kt | 13 + .../AddContentToFavouriteUseCaseTest.kt | 51 ++ .../core/content/ContentRepositoryTest.kt | 153 +++++ .../core/content/FetchContentUseCaseTest.kt | 49 ++ .../core/content/GetAllContentUseCaseTest.kt | 214 +++++++ .../RemoveContentFromFavouritesUseCaseTest.kt | 51 ++ .../core/login/IsUserLoggedInUseCaseTest.kt | 61 ++ .../showcase/core/login/LoginUseCaseTest.kt | 95 +++ .../showcase/core/login/LogoutUseCaseTest.kt | 65 ++ .../session/SessionExpirationAdapterTest.kt | 35 ++ .../showcase/core/shared/AnswerUtilsKtTest.kt | 79 +++ .../NetworkSessionLocalStorageAdapterTest.kt | 55 ++ .../org.mockito.plugins.MockMaker | 1 + detekt/detekt.yml | 579 ++++++++++++++++++ gradle.properties | 21 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlescripts/versions.gradle | 28 + gradlew | 172 ++++++ gradlew.bat | 84 +++ mockserver/.gitignore | 1 + mockserver/build.gradle | 22 + .../network/mockserver/ContentData.kt | 45 ++ .../mockserver/MockServerScenarioSetup.kt | 95 +++ .../network/mockserver/NetworkDispatcher.kt | 29 + .../mockserver/ScenarioToRequestScenario.kt | 80 +++ .../scenario/RequestMatchingChecker.kt | 11 + .../mockserver/scenario/RequestScenario.kt | 9 + .../scenario/RequestScenarioChain.kt | 19 + .../scenario/SpecificRequestScenario.kt | 34 + .../auth/AuthRequestMatchingChecker.kt | 39 ++ .../mockserver/scenario/auth/AuthScenario.kt | 15 + .../CreateAuthInvalidCredentialsResponse.kt | 10 + .../auth/CreateAuthSuccessResponse.kt | 13 + .../content/ContentRequestMatchingChecker.kt | 29 + .../scenario/content/ContentScenario.kt | 15 + .../content/CreateContentSuccessResponse.kt | 13 + .../CreateContentSuccessWithMissingFields.kt | 13 + .../CreateGeneralErrorResponse.kt | 7 + .../CreateGenericSuccessResponseByJson.kt | 7 + .../CreateMalformedJsonSuccessResponse.kt | 7 + .../scenario/createresponse/CreateResponse.kt | 8 + .../CreateUnauthorizedResponse.kt | 8 + .../scenario/general/GenericScenario.kt | 14 + .../general/NotFoundRequestScenario.kt | 10 + .../scenario/refresh/CreateRefreshResponse.kt | 12 + .../refresh/RefreshRequestMatchingChecker.kt | 21 + .../scenario/refresh/RefreshTokenScenario.kt | 10 + .../network/mockserver/utils/ResourceUtils.kt | 20 + .../response/auth/success_response_login.json | 4 + .../content_missing_field_response.json | 13 + .../content/success_response_content.json | 20 + .../refresh/success_response_refresh.json | 4 + model/.gitignore | 1 + model/build.gradle | 19 + .../showcase/model/auth/LoginCredentials.kt | 3 + .../test/showcase/model/auth/LoginStatus.kt | 5 + .../test/showcase/model/content/Content.kt | 3 + .../test/showcase/model/content/ContentId.kt | 3 + .../model/content/FavouriteContent.kt | 3 + .../test/showcase/model/content/ImageUrl.kt | 3 + .../test/showcase/model/network/BaseUrl.kt | 3 + .../test/showcase/model/session/Session.kt | 3 + .../test/showcase/model/shared/Answer.kt | 6 + .../test/showcase/model/shared/Resource.kt | 20 + network/build.gradle | 41 ++ .../network/auth/LoginErrorConverter.kt | 35 ++ .../network/auth/LoginRemoteSource.kt | 12 + .../network/auth/LoginRemoteSourceImpl.kt | 27 + .../showcase/network/auth/LoginService.kt | 18 + .../network/auth/model/CredentialsRequest.kt | 12 + .../network/auth/model/LoginResponse.kt | 12 + .../auth/model/LoginStatusResponses.kt | 8 + .../network/content/ContentRemoteSource.kt | 12 + .../content/ContentRemoteSourceImpl.kt | 26 + .../network/content/ContentResponse.kt | 16 + .../network/content/ContentService.kt | 9 + .../network/di/OkhttpClientExtension.kt | 12 + .../network/di/createNetworkmodules.kt | 87 +++ .../AuthenticationHeaderInterceptor.kt | 12 + .../session/AuthenticationHeaderUtils.kt | 16 + .../NetworkSessionExpirationListener.kt | 6 + .../session/NetworkSessionLocalStorage.kt | 8 + .../network/session/SessionAuthenticator.kt | 34 + .../network/shared/ExceptionWrapper.kt | 27 + .../network/shared/PlatformInterceptor.kt | 12 + .../shared/exceptions/NetworkException.kt | 3 + .../shared/exceptions/ParsingException.kt | 3 + .../network/auth/LoginErrorConverterTest.kt | 71 +++ .../LoginRemoteSourceRefreshActionImplTest.kt | 105 ++++ .../network/auth/LoginRemoteSourceTest.kt | 120 ++++ .../content/ContentRemoteSourceImplTest.kt | 124 ++++ .../network/content/SessionExpirationTest.kt | 114 ++++ .../MockServerScenarioSetupExtensions.kt | 21 + settings.gradle | 6 + 221 files changed, 7611 insertions(+) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestLoginRobotConfiguration.kt create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestMainDispatcherTestRule.kt create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestServerTypeConfiguration.kt create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationTestRule.kt create mode 100644 app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/org/fnives/test/showcase/TestShowcaseApplication.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/di/BaseUrlProvider.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/di/createAppModules.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/session/SessionExpirationListenerImpl.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/storage/SharedPreferencesManagerImpl.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImpl.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteDao.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/auth/AuthActivity.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/auth/AuthViewModel.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/auth/SetTextIfNotSameObserver.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/home/FavouriteContentAdapter.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/home/MainActivity.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/home/MainViewModel.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/shared/VerticalSpaceItemDecoration.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/shared/ViewBindingAdapter.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/shared/ViewExtension.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivity.kt create mode 100644 app/src/main/java/org/fnives/test/showcase/ui/splash/SplashViewModel.kt create mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/favorite_24.xml create mode 100644 app/src/main/res/drawable/favorite_border_24.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/logout_24.xml create mode 100644 app/src/main/res/layout/activity_authentication.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_splash.xml create mode 100644 app/src/main/res/layout/item_favourite_content.xml create mode 100644 app/src/main/res/menu/main.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricLoginRobotConfiguration.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricServerTypeConfiguration.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationTestRule.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt create mode 100644 app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/di/BaseUrlProvider.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/LoginRobotConfiguration.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/MainDispatcherTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/ServerTypeConfiguration.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackBarTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/doBlockinglyOnMainThread.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/CompositeDisposable.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/Disposable.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/IdlingResourceDisposable.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronization.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupLoggedInState.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/LoopMainThreadFor.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/PullToRefresh.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/SwipeRefreshLayoutExtension.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/WithDrawable.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/notIntended.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityTest.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityTest.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt create mode 100644 app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/di/DITest.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/testutils/InstantExecutorExtension.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/ui/shared/EventTest.kt create mode 100644 app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt create mode 100644 app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 app/src/test/resources/robolectric.properties create mode 100644 build.gradle create mode 100644 core/.gitignore create mode 100644 core/build.gradle create mode 100644 core/src/main/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCase.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/content/ContentRepository.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/content/FetchContentUseCase.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCase.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/di/createCoreModule.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCase.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/login/LoginUseCase.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/login/LogoutUseCase.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationAdapter.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationListener.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/shared/AnswerUtils.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/shared/Optional.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/shared/UnexpectedException.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapter.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/storage/UserDataLocalStorage.kt create mode 100644 core/src/main/java/org/fnives/test/showcase/core/storage/content/FavouriteContentLocalStorage.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCaseTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/login/LogoutUseCaseTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/session/SessionExpirationAdapterTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt create mode 100644 core/src/test/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapterTest.kt create mode 100644 core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker create mode 100644 detekt/detekt.yml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlescripts/versions.gradle create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 mockserver/.gitignore create mode 100644 mockserver/build.gradle create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ContentData.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/MockServerScenarioSetup.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/NetworkDispatcher.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ScenarioToRequestScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestMatchingChecker.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenarioChain.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/SpecificRequestScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthRequestMatchingChecker.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthInvalidCredentialsResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthSuccessResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentRequestMatchingChecker.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessWithMissingFields.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGeneralErrorResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGenericSuccessResponseByJson.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateMalformedJsonSuccessResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateUnauthorizedResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/GenericScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/NotFoundRequestScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/CreateRefreshResponse.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshRequestMatchingChecker.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshTokenScenario.kt create mode 100644 mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/utils/ResourceUtils.kt create mode 100644 mockserver/src/main/resources/response/auth/success_response_login.json create mode 100644 mockserver/src/main/resources/response/content/content_missing_field_response.json create mode 100644 mockserver/src/main/resources/response/content/success_response_content.json create mode 100644 mockserver/src/main/resources/response/refresh/success_response_refresh.json create mode 100644 model/.gitignore create mode 100644 model/build.gradle create mode 100644 model/src/main/java/org/fnives/test/showcase/model/auth/LoginCredentials.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/auth/LoginStatus.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/content/Content.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/content/ContentId.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/content/FavouriteContent.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/content/ImageUrl.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/network/BaseUrl.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/session/Session.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/shared/Answer.kt create mode 100644 model/src/main/java/org/fnives/test/showcase/model/shared/Resource.kt create mode 100644 network/build.gradle create mode 100644 network/src/main/java/org/fnives/test/showcase/network/auth/LoginErrorConverter.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSource.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceImpl.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/auth/LoginService.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/auth/model/CredentialsRequest.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginResponse.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginStatusResponses.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSource.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImpl.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/content/ContentResponse.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/content/ContentService.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/di/OkhttpClientExtension.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/di/createNetworkmodules.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderInterceptor.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderUtils.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionExpirationListener.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionLocalStorage.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/session/SessionAuthenticator.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/shared/ExceptionWrapper.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/shared/PlatformInterceptor.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/NetworkException.kt create mode 100644 network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/ParsingException.kt create mode 100644 network/src/test/java/org/fnives/test/showcase/network/auth/LoginErrorConverterTest.kt create mode 100644 network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceRefreshActionImplTest.kt create mode 100644 network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceTest.kt create mode 100644 network/src/test/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImplTest.kt create mode 100644 network/src/test/java/org/fnives/test/showcase/network/content/SessionExpirationTest.kt create mode 100644 network/src/test/java/org/fnives/test/showcase/network/shared/MockServerScenarioSetupExtensions.kt create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..276f837 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +app/app.iml +.idea/ +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +*.jks +*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +lint/reports/ \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..2a7b3cf --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,144 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.3" + + defaultConfig { + applicationId "org.fnives.test.showcase" + minSdkVersion 21 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"' + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + buildFeatures { + viewBinding true + } + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + androidTest { + java.srcDirs += "src/sharedTest/java" + } + test { + java.srcDirs += "src/sharedTest/java" + java.srcDirs += "src/robolectricTest/java" + } + } + + testOptions.unitTests.all { + useJUnitPlatform() + testLogging { + events 'started', 'passed', 'skipped', 'failed' + exceptionFormat "full" + showStandardStreams true + } + } + testOptions { + unitTests { + includeAndroidResources = true + } + } + // 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' + } + + lintOptions { + warningsAsErrors true + abortOnError true + textReport true + ignore 'Overdraw' + textOutput "stdout" + } +} + +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 "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + 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.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version" + + // Koin + implementation "org.koin:koin-androidx-scope:$koin_version" + implementation "org.koin:koin-androidx-viewmodel:$koin_version" + implementation "org.koin:koin-androidx-fragment:$koin_version" + + implementation "androidx.room:room-runtime:$androidx_room_version" + kapt "androidx.room:room-compiler:$androidx_room_version" + implementation "androidx.room:room-ktx:$androidx_room_version" + + implementation "io.coil-kt:coil:$coil_version" + + implementation project(":core") + releaseImplementation project(":core") + + testImplementation "androidx.room:room-testing:$androidx_room_version" + testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" + testImplementation "org.junit.jupiter:junit-jupiter-params:$testing_junit5_version" + testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "com.jraska.livedata:testing-ktx:$testing_livedata_version" + testImplementation "org.koin:koin-test:$koin_version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" + + // robolectric specific + testImplementation "junit:junit:$testing_junit4_version" + testImplementation "org.robolectric:robolectric:$testing_robolectric_version" + testImplementation "androidx.test:core:$testing_androidx_code_version" + testImplementation "androidx.test:runner:$testing_androidx_code_version" + testImplementation "androidx.test.ext:junit:$testing_androidx_junit_version" + testImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version" + testImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version" + testImplementation project(':mockserver') + testImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version" + testImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" + testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version" + + androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + androidTestImplementation "org.koin:koin-test:$koin_version" + androidTestImplementation "junit:junit:$testing_junit4_version" + androidTestImplementation "androidx.test:core:$testing_androidx_code_version" + androidTestImplementation "androidx.test:runner:$testing_androidx_code_version" + androidTestImplementation "androidx.test.ext:junit:$testing_androidx_junit_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version" + androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version" + androidTestImplementation project(':mockserver') + androidTestImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version" + androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" + androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/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/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestLoginRobotConfiguration.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestLoginRobotConfiguration.kt new file mode 100644 index 0000000..edccc1f --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestLoginRobotConfiguration.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.testutils.configuration + +object AndroidTestLoginRobotConfiguration : LoginRobotConfiguration { + override val assertLoadingBeforeRequest: Boolean get() = false +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestMainDispatcherTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestMainDispatcherTestRule.kt new file mode 100644 index 0000000..c2f11f5 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestMainDispatcherTestRule.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.testutils.configuration + +import androidx.test.espresso.Espresso +import androidx.test.espresso.NoActivityResumedException +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import kotlinx.coroutines.Dispatchers +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.testutils.idling.loopMainThreadFor +import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class AndroidTestMainDispatcherTestRule : MainDispatcherTestRule { + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + DatabaseInitialization.dispatcher = Dispatchers.Main.immediate + base.evaluate() + } + } + + override fun advanceUntilIdleWithIdlingResources() { + loopMainThreadUntilIdleWithIdlingResources() + } + + override fun advanceUntilIdleOrActivityIsDestroyed() { + try { + advanceUntilIdleWithIdlingResources() + Espresso.onView(ViewMatchers.isRoot()).check(ViewAssertions.doesNotExist()) + } catch (noActivityResumedException: NoActivityResumedException) { + // expected to happen + } catch (runtimeException: RuntimeException) { + if (runtimeException.message?.contains("No activities found") == true) { + // expected to happen + } else { + throw runtimeException + } + } + } + + override fun advanceUntilIdle() { + loopMainThreadUntilIdleWithIdlingResources() + } + + override fun advanceTimeBy(delayInMillis: Long) { + loopMainThreadFor(delayInMillis) + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestServerTypeConfiguration.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestServerTypeConfiguration.kt new file mode 100644 index 0000000..46a59a3 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestServerTypeConfiguration.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.testutils.configuration + +import okhttp3.OkHttpClient +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.testutils.idling.NetworkSynchronization +import org.koin.core.context.loadKoinModules +import org.koin.core.qualifier.StringQualifier +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.get + +object AndroidTestServerTypeConfiguration : ServerTypeConfiguration, KoinTest { + override val useHttps: Boolean get() = true + + override val url: String get() = "${MockServerScenarioSetup.HTTPS_BASE_URL}:${MockServerScenarioSetup.PORT}/" + + override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) { + val handshakeCertificates = mockServerScenarioSetup.clientCertificates ?: return + val sessionless = StringQualifier(NetworkSynchronization.OkHttpClientTypes.SESSIONLESS.qualifier) + val okHttpClientWithCertificate = get(sessionless).newBuilder() + .sslSocketFactory(handshakeCertificates.sslSocketFactory(), handshakeCertificates.trustManager) + .build() + loadKoinModules( + module { + single(qualifier = sessionless, override = true) { okHttpClientWithCertificate } + } + ) + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationTestRule.kt new file mode 100644 index 0000000..9a12237 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationTestRule.kt @@ -0,0 +1,44 @@ +package org.fnives.test.showcase.testutils.configuration + +import android.view.View +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import com.google.android.material.R +import com.google.android.material.snackbar.Snackbar +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.junit.runner.Description +import org.junit.runners.model.Statement + +object AndroidTestSnackbarVerificationTestRule : SnackbarVerificationTestRule { + + override fun apply(base: Statement, description: Description): Statement = base + + override fun assertIsShownWithText(@StringRes stringResID: Int) { + Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)) + .check(ViewAssertions.matches(ViewMatchers.withText(stringResID))) + Espresso.onView(ViewMatchers.isAssignableFrom(Snackbar.SnackbarLayout::class.java)).perform(ViewActions.swipeRight()) + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainUntilSnackbarDismissed()) + } + + override fun assertIsNotShown() { + Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)).check(ViewAssertions.doesNotExist()) + } + + class LoopMainUntilSnackbarDismissed() : ViewAction { + override fun getConstraints(): Matcher = Matchers.isA(View::class.java) + + override fun getDescription(): String = "loop MainThread until Snackbar is Dismissed" + + override fun perform(uiController: UiController, view: View?) { + while (view?.findViewById(com.google.android.material.R.id.snackbar_text) != null) { + uiController.loopMainThreadForAtLeast(100) + } + } + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt new file mode 100644 index 0000000..af65008 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.testutils.configuration + +object SpecificTestConfigurationsFactory : TestConfigurationsFactory { + override fun createMainDispatcherTestRule(): MainDispatcherTestRule = + AndroidTestMainDispatcherTestRule() + + override fun createServerTypeConfiguration(): ServerTypeConfiguration = + AndroidTestServerTypeConfiguration + + override fun createLoginRobotConfiguration(): LoginRobotConfiguration = + AndroidTestLoginRobotConfiguration + + override fun createSnackbarVerification(): SnackbarVerificationTestRule = + AndroidTestSnackbarVerificationTestRule +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1000bd1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..8630fd1f42de5350d97146d3d37574169a5c5eb2 GIT binary patch literal 14084 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 ZdiN^1@{jB6fWJtBsVQk+D^aiv{$E})&;kGe literal 0 HcmV?d00001 diff --git a/app/src/main/java/org/fnives/test/showcase/TestShowcaseApplication.kt b/app/src/main/java/org/fnives/test/showcase/TestShowcaseApplication.kt new file mode 100644 index 0000000..60b05b4 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/TestShowcaseApplication.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase + +import android.app.Application +import org.fnives.test.showcase.di.BaseUrlProvider +import org.fnives.test.showcase.di.createAppModules +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class TestShowcaseApplication : Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@TestShowcaseApplication) + modules(createAppModules(BaseUrlProvider.get())) + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/di/BaseUrlProvider.kt b/app/src/main/java/org/fnives/test/showcase/di/BaseUrlProvider.kt new file mode 100644 index 0000000..9b5e5ba --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/di/BaseUrlProvider.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.di + +import org.fnives.test.showcase.BuildConfig +import org.fnives.test.showcase.model.network.BaseUrl + +object BaseUrlProvider { + + fun get() = BaseUrl(BuildConfig.BASE_URL) +} diff --git a/app/src/main/java/org/fnives/test/showcase/di/createAppModules.kt b/app/src/main/java/org/fnives/test/showcase/di/createAppModules.kt new file mode 100644 index 0000000..8d05715 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/di/createAppModules.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.di + +import org.fnives.test.showcase.core.di.createCoreModule +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.session.SessionExpirationListenerImpl +import org.fnives.test.showcase.storage.LocalDatabase +import org.fnives.test.showcase.storage.SharedPreferencesManagerImpl +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl +import org.fnives.test.showcase.ui.auth.AuthViewModel +import org.fnives.test.showcase.ui.home.MainViewModel +import org.fnives.test.showcase.ui.splash.SplashViewModel +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.Module +import org.koin.dsl.module + +fun createAppModules(baseUrl: BaseUrl): List { + return createCoreModule( + baseUrl = baseUrl, + true, + userDataLocalStorageProvider = { get() }, + sessionExpirationListenerProvider = { get() }, + favouriteContentLocalStorageProvider = { get() } + ) + .plus(storageModule()) + .plus(authModule()) + .plus(appModule()) + .plus(favouriteModule()) + .plus(splashModule()) + .toList() +} + +fun storageModule() = module { + single { SharedPreferencesManagerImpl.create(androidContext()) } + single { DatabaseInitialization.create(androidContext()) } +} + +fun authModule() = module { + viewModel { AuthViewModel(get()) } +} + +fun appModule() = module { + single { SessionExpirationListenerImpl(androidContext()) } +} + +fun splashModule() = module { + viewModel { SplashViewModel(get()) } +} + +fun favouriteModule() = module { + single { get().favouriteDao } + viewModel { MainViewModel(get(), get(), get(), get(), get()) } + single { FavouriteContentLocalStorageImpl(get()) } +} diff --git a/app/src/main/java/org/fnives/test/showcase/session/SessionExpirationListenerImpl.kt b/app/src/main/java/org/fnives/test/showcase/session/SessionExpirationListenerImpl.kt new file mode 100644 index 0000000..230a521 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/session/SessionExpirationListenerImpl.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.session + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import org.fnives.test.showcase.core.session.SessionExpirationListener +import org.fnives.test.showcase.ui.auth.AuthActivity + +class SessionExpirationListenerImpl(private val context: Context) : SessionExpirationListener { + + override fun onSessionExpired() { + Handler(Looper.getMainLooper()).post { + context.startActivity( + AuthActivity.getStartIntent(context) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt b/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt new file mode 100644 index 0000000..3a78802 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.storage + +import androidx.room.Database +import androidx.room.RoomDatabase +import org.fnives.test.showcase.storage.favourite.FavouriteDao +import org.fnives.test.showcase.storage.favourite.FavouriteEntity + +@Database(entities = [FavouriteEntity::class], version = 1, exportSchema = false) +abstract class LocalDatabase : RoomDatabase() { + + abstract val favouriteDao: FavouriteDao +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/SharedPreferencesManagerImpl.kt b/app/src/main/java/org/fnives/test/showcase/storage/SharedPreferencesManagerImpl.kt new file mode 100644 index 0000000..a62af66 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/SharedPreferencesManagerImpl.kt @@ -0,0 +1,58 @@ +package org.fnives.test.showcase.storage + +import android.content.Context +import android.content.SharedPreferences +import org.fnives.test.showcase.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/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt b/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt new file mode 100644 index 0000000..02606d0 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.storage.database + +import android.content.Context +import androidx.room.Room +import org.fnives.test.showcase.storage.LocalDatabase + +object DatabaseInitialization { + + fun create(context: Context): LocalDatabase = + Room.databaseBuilder(context, LocalDatabase::class.java, "local_database") + .allowMainThreadQueries() + .build() +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImpl.kt b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImpl.kt new file mode 100644 index 0000000..ac0f9d9 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImpl.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.storage.favourite + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class FavouriteContentLocalStorageImpl(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/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteDao.kt b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteDao.kt new file mode 100644 index 0000000..93d48be --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteDao.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.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/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt new file mode 100644 index 0000000..1d2a280 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.storage.favourite + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class FavouriteEntity(@PrimaryKey val contentId: String) diff --git a/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthActivity.kt new file mode 100644 index 0000000..3e4157c --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthActivity.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.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 org.fnives.test.showcase.R +import org.fnives.test.showcase.databinding.ActivityAuthenticationBinding +import org.fnives.test.showcase.ui.home.MainActivity +import org.koin.androidx.viewmodel.ext.android.viewModel + +class AuthActivity : AppCompatActivity() { + + private val viewModel by viewModel() + + 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(MainActivity.getStartIntent(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/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthViewModel.kt b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..0221f14 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthViewModel.kt @@ -0,0 +1,66 @@ +package org.fnives.test.showcase.ui.auth + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.fnives.test.showcase.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 +import org.fnives.test.showcase.ui.shared.Event + +class AuthViewModel(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/app/src/main/java/org/fnives/test/showcase/ui/auth/SetTextIfNotSameObserver.kt b/app/src/main/java/org/fnives/test/showcase/ui/auth/SetTextIfNotSameObserver.kt new file mode 100644 index 0000000..ff48bea --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/auth/SetTextIfNotSameObserver.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.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/app/src/main/java/org/fnives/test/showcase/ui/home/FavouriteContentAdapter.kt b/app/src/main/java/org/fnives/test/showcase/ui/home/FavouriteContentAdapter.kt new file mode 100644 index 0000000..a4e5b58 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/home/FavouriteContentAdapter.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.ui.home + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.fnives.test.showcase.R +import org.fnives.test.showcase.databinding.ItemFavouriteContentBinding +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.ui.shared.ViewBindingAdapter +import org.fnives.test.showcase.ui.shared.layoutInflater +import org.fnives.test.showcase.ui.shared.loadRoundedImage + +class FavouriteContentAdapter( + private val listener: OnFavouriteItemClicked, +) : ListAdapter>( + DiffUtilItemCallback() +) { + + 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/app/src/main/java/org/fnives/test/showcase/ui/home/MainActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/home/MainActivity.kt new file mode 100644 index 0000000..aef19a9 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/home/MainActivity.kt @@ -0,0 +1,68 @@ +package org.fnives.test.showcase.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 org.fnives.test.showcase.R +import org.fnives.test.showcase.databinding.ActivityMainBinding +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.fnives.test.showcase.ui.shared.VerticalSpaceItemDecoration +import org.fnives.test.showcase.ui.shared.getThemePrimaryColor +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MainActivity : AppCompatActivity() { + + private val viewModel by viewModel() + + 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(AuthActivity.getStartIntent(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/app/src/main/java/org/fnives/test/showcase/ui/home/MainViewModel.kt b/app/src/main/java/org/fnives/test/showcase/ui/home/MainViewModel.kt new file mode 100644 index 0000000..326ac01 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/home/MainViewModel.kt @@ -0,0 +1,81 @@ +package org.fnives.test.showcase.ui.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.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 +import org.fnives.test.showcase.ui.shared.Event + +class MainViewModel( + 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 + 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/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt new file mode 100644 index 0000000..30a323c --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.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/app/src/main/java/org/fnives/test/showcase/ui/shared/VerticalSpaceItemDecoration.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/VerticalSpaceItemDecoration.kt new file mode 100644 index 0000000..bdc452b --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/VerticalSpaceItemDecoration.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.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/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewBindingAdapter.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewBindingAdapter.kt new file mode 100644 index 0000000..719a4aa --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewBindingAdapter.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.ui.shared + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +class ViewBindingAdapter(val viewBinding: T) : RecyclerView.ViewHolder(viewBinding.root) diff --git a/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewExtension.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewExtension.kt new file mode 100644 index 0000000..56d514c --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewExtension.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.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.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/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivity.kt new file mode 100644 index 0000000..ab7ee68 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivity.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.ui.splash + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import org.fnives.test.showcase.R +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.fnives.test.showcase.ui.home.MainActivity +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SplashActivity : AppCompatActivity() { + + private val viewModel by viewModel() + + 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 -> MainActivity.getStartIntent(this) + SplashViewModel.NavigateTo.AUTHENTICATION -> AuthActivity.getStartIntent(this) + null -> return@observe + } + startActivity(intent) + finishAffinity() + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashViewModel.kt b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashViewModel.kt new file mode 100644 index 0000000..d9bbd43 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashViewModel.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.ui.splash + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.ui.shared.Event + +class SplashViewModel(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/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..da1dcd9 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/favorite_24.xml b/app/src/main/res/drawable/favorite_24.xml new file mode 100644 index 0000000..209e42e --- /dev/null +++ b/app/src/main/res/drawable/favorite_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/favorite_border_24.xml b/app/src/main/res/drawable/favorite_border_24.xml new file mode 100644 index 0000000..83e57ce --- /dev/null +++ b/app/src/main/res/drawable/favorite_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b219d51 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/logout_24.xml b/app/src/main/res/drawable/logout_24.xml new file mode 100644 index 0000000..77928df --- /dev/null +++ b/app/src/main/res/drawable/logout_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/activity_authentication.xml b/app/src/main/res/layout/activity_authentication.xml new file mode 100644 index 0000000..f012327 --- /dev/null +++ b/app/src/main/res/layout/activity_authentication.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..908365c --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..c758e5a --- /dev/null +++ b/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_favourite_content.xml b/app/src/main/res/layout/item_favourite_content.xml new file mode 100644 index 0000000..c4115ca --- /dev/null +++ b/app/src/main/res/layout/item_favourite_content.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml new file mode 100644 index 0000000..f42dec2 --- /dev/null +++ b/app/src/main/res/menu/main.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/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/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/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/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..2c7df4cd7c9d09aa0f6cd4b6a277eecab8bfb2d9 GIT binary patch literal 1331 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 il<(oymer;6asEGfAq8aLtL9h$00002 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..aa9c11522b25853c7db70976b2eeeecba8ea3f1d GIT binary patch literal 2491 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 H_qq9B{?ncQ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/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/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4e197d3 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/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/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..67abbea --- /dev/null +++ b/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/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f84909a --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + 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/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..64b01cd --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt new file mode 100644 index 0000000..da79993 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt @@ -0,0 +1,91 @@ +package org.fnives.test.showcase.favourite + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.storage.database.DatabaseInitialization +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.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +internal class FavouriteContentLocalStorageImplTest : KoinTest { + + private val sut by inject() + private lateinit var testDispatcher: TestCoroutineDispatcher + + @Before + fun setUp() { + testDispatcher = TestCoroutineDispatcher() + DatabaseInitialization.dispatcher = testDispatcher + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun GIVEN_content_id_WHEN_added_to_Favourite_THEN_it_can_be_read_out() = runBlocking { + val expected = listOf(ContentId("a")) + + sut.markAsFavourite(ContentId("a")) + val actual = sut.observeFavourites().first() + + Assert.assertEquals(expected, actual) + } + + @Test + fun GIVEN_content_id_added_WHEN_removed_to_Favourite_THEN_it_no_longer_can_be_read_out() = runBlocking { + val expected = listOf() + sut.markAsFavourite(ContentId("b")) + + sut.deleteAsFavourite(ContentId("b")) + val actual = sut.observeFavourites().first() + + Assert.assertEquals(expected, actual) + } + + @Test + fun GIVEN_empty_database_WHILE_observing_content_WHEN_favourite_added_THEN_change_is_emitted() = + runBlocking { + val expected = listOf(listOf(), listOf(ContentId("a"))) + + val actual = async(testDispatcher) { + sut.observeFavourites().take(2).toList() + } + testDispatcher.advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + + Assert.assertEquals(expected, actual.await()) + } + + @Test + fun GIVEN_non_empty_database_WHILE_observing_content_WHEN_favourite_removed_THEN_change_is_emitted() = + runBlocking { + val expected = listOf(listOf(ContentId("a")), listOf()) + sut.markAsFavourite(ContentId("a")) + + val actual = async(testDispatcher) { + sut.observeFavourites().take(2).toList() + } + testDispatcher.advanceUntilIdle() + + sut.deleteAsFavourite(ContentId("a")) + + Assert.assertEquals(expected, actual.await()) + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricLoginRobotConfiguration.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricLoginRobotConfiguration.kt new file mode 100644 index 0000000..dbe6927 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricLoginRobotConfiguration.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.testutils.configuration + +object RobolectricLoginRobotConfiguration : LoginRobotConfiguration { + override val assertLoadingBeforeRequest: Boolean = true +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricServerTypeConfiguration.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricServerTypeConfiguration.kt new file mode 100644 index 0000000..895f466 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricServerTypeConfiguration.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup + +object RobolectricServerTypeConfiguration : ServerTypeConfiguration { + override val useHttps: Boolean = false + + override val url: String get() = "${MockServerScenarioSetup.HTTP_BASE_URL}:${MockServerScenarioSetup.PORT}/" + + override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) = Unit +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationTestRule.kt new file mode 100644 index 0000000..565a518 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationTestRule.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils.configuration + +import androidx.annotation.StringRes +import org.fnives.test.showcase.testutils.shadow.ShadowSnackbar +import org.fnives.test.showcase.testutils.shadow.ShadowSnackbarResetTestRule +import org.junit.Assert +import org.junit.rules.TestRule + +object RobolectricSnackbarVerificationTestRule : SnackbarVerificationTestRule, TestRule by ShadowSnackbarResetTestRule() { + + override fun assertIsShownWithText(@StringRes stringResID: Int) { + val latestSnackbar = ShadowSnackbar.latestSnackbar ?: throw IllegalStateException("Snackbar not found") + Assert.assertEquals(latestSnackbar.context.getString(stringResID), ShadowSnackbar.textOfLatestSnackbar) + } + + override fun assertIsNotShown() { + Assert.assertNull(ShadowSnackbar.latestSnackbar) + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt new file mode 100644 index 0000000..66b0f47 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.testutils.configuration + +object SpecificTestConfigurationsFactory : TestConfigurationsFactory { + override fun createMainDispatcherTestRule(): MainDispatcherTestRule = + TestCoroutineMainDispatcherTestRule() + + override fun createServerTypeConfiguration(): ServerTypeConfiguration = + RobolectricServerTypeConfiguration + + override fun createLoginRobotConfiguration(): LoginRobotConfiguration = + RobolectricLoginRobotConfiguration + + override fun createSnackbarVerification(): SnackbarVerificationTestRule = + RobolectricSnackbarVerificationTestRule +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt new file mode 100644 index 0000000..4706794 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt @@ -0,0 +1,48 @@ +package org.fnives.test.showcase.testutils.configuration + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.testutils.idling.advanceUntilIdleWithIdlingResources +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class TestCoroutineMainDispatcherTestRule : MainDispatcherTestRule { + + private lateinit var testDispatcher: TestCoroutineDispatcher + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + val dispatcher = TestCoroutineDispatcher() + dispatcher.pauseDispatcher() + Dispatchers.setMain(dispatcher) + testDispatcher = dispatcher + DatabaseInitialization.dispatcher = dispatcher + try { + base.evaluate() + } finally { + Dispatchers.resetMain() + } + } + } + + override fun advanceUntilIdleWithIdlingResources() { + testDispatcher.advanceUntilIdleWithIdlingResources() + } + + override fun advanceUntilIdleOrActivityIsDestroyed() { + advanceUntilIdleWithIdlingResources() + } + + override fun advanceUntilIdle() { + testDispatcher.advanceUntilIdle() + } + + override fun advanceTimeBy(delayInMillis: Long) { + testDispatcher.advanceTimeBy(delayInMillis) + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt new file mode 100644 index 0000000..f5d8193 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt @@ -0,0 +1,100 @@ +package org.fnives.test.showcase.testutils.shadow + +import android.R +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.StringRes +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.snackbar.ContentViewCallback +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.snackbar.SnackbarContentLayout +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.RealObject +import org.robolectric.shadow.api.Shadow.extract +import java.lang.reflect.Modifier + +@Implements(Snackbar::class) +class ShadowSnackbar { + @RealObject + var snackbar: Snackbar? = null + var text: String? = null + + companion object { + val shadowSnackbars = mutableListOf() + + @Implementation + @JvmStatic + fun make(view: View, text: CharSequence, duration: Int): Snackbar? { + var snackbar: Snackbar? = null + try { + val constructor = Snackbar::class.java.getDeclaredConstructor( + Context::class.java, + ViewGroup::class.java, + View::class.java, + ContentViewCallback::class.java + ) ?: throw IllegalArgumentException("Seems like the constructor was not found!") + if (Modifier.isPrivate(constructor.modifiers)) { + constructor.isAccessible = true + } + val parent = findSuitableParent(view) + val content = LayoutInflater.from(parent.context) + .inflate( + com.google.android.material.R.layout.design_layout_snackbar_include, + parent, + false + ) as SnackbarContentLayout + snackbar = constructor.newInstance(view.context, parent, content, content) + snackbar.setText(text) + snackbar.duration = duration + } catch (e: Exception) { + e.printStackTrace() + throw e + } + shadowOf(snackbar).text = text.toString() + shadowSnackbars.add(shadowOf(snackbar)) + return snackbar + } + + private fun findSuitableParent(view: View): ViewGroup = + when (view) { + is CoordinatorLayout -> view + is FrameLayout -> { + when { + view.id == R.id.content -> view + (view.parent as? View) == null -> view + else -> findSuitableParent(view.parent as View) + } + } + else -> { + when { + (view.parent as? View) == null && view is ViewGroup -> view + (view.parent as? View) == null -> FrameLayout(view.context) + else -> findSuitableParent(view.parent as View) + } + } + } + + @Implementation + @JvmStatic + fun make(view: View, @StringRes resId: Int, duration: Int): Snackbar? = + make(view, view.resources.getText(resId), duration) + + fun shadowOf(bar: Snackbar?): ShadowSnackbar = + extract(bar) + + fun reset() { + shadowSnackbars.clear() + } + + fun shownSnackbarCount(): Int = shadowSnackbars.size + + val textOfLatestSnackbar: String? + get() = shadowSnackbars.lastOrNull()?.text + val latestSnackbar: Snackbar? + get() = shadowSnackbars.lastOrNull()?.snackbar + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt new file mode 100644 index 0000000..3fa4de9 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.testutils.shadow + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class ShadowSnackbarResetTestRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + ShadowSnackbar.reset() + try { + base.evaluate() + } finally { + ShadowSnackbar.reset() + } + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/di/BaseUrlProvider.kt b/app/src/sharedTest/java/org/fnives/test/showcase/di/BaseUrlProvider.kt new file mode 100644 index 0000000..4be8d81 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/di/BaseUrlProvider.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.di + +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory + +object BaseUrlProvider { + + fun get() = BaseUrl(SpecificTestConfigurationsFactory.createServerTypeConfiguration().url) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt b/app/src/sharedTest/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt new file mode 100644 index 0000000..9a6277b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.storage.database + +import android.content.Context +import androidx.room.Room +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import org.fnives.test.showcase.storage.LocalDatabase + +object DatabaseInitialization { + + lateinit var dispatcher: CoroutineDispatcher + + fun create(context: Context): LocalDatabase { + val executor = dispatcher.asExecutor() + return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java) + .setTransactionExecutor(executor) + .setQueryExecutor(executor) + .allowMainThreadQueries() + .build() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupTestRule.kt new file mode 100644 index 0000000..df076ab --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupTestRule.kt @@ -0,0 +1,36 @@ +package org.fnives.test.showcase.testutils + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.testutils.configuration.ServerTypeConfiguration +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class MockServerScenarioSetupTestRule( + val serverTypeConfiguration: ServerTypeConfiguration = SpecificTestConfigurationsFactory.createServerTypeConfiguration() +) : TestRule { + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + before() + try { + base.evaluate() + } finally { + after() + } + } + } + + private fun before() { + mockServerScenarioSetup = MockServerScenarioSetup() + mockServerScenarioSetup.start(serverTypeConfiguration.useHttps) + } + + private fun after() { + mockServerScenarioSetup.stop() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt new file mode 100644 index 0000000..5e65a0f --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.testutils + +import androidx.test.core.app.ApplicationProvider +import org.fnives.test.showcase.TestShowcaseApplication +import org.fnives.test.showcase.di.BaseUrlProvider +import org.fnives.test.showcase.di.createAppModules +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin + +class ReloadKoinModulesIfNecessaryTestRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + override fun evaluate() { + if (GlobalContext.getOrNull() == null) { + val application = ApplicationProvider.getApplicationContext() + startKoin { + androidContext(application) + modules(createAppModules(BaseUrlProvider.get())) + } + } + try { + base.evaluate() + } finally { + stopKoin() + } + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/LoginRobotConfiguration.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/LoginRobotConfiguration.kt new file mode 100644 index 0000000..1cc74df --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/LoginRobotConfiguration.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.testutils.configuration + +interface LoginRobotConfiguration { + + val assertLoadingBeforeRequest: Boolean +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/MainDispatcherTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/MainDispatcherTestRule.kt new file mode 100644 index 0000000..6104117 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/MainDispatcherTestRule.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.junit.rules.TestRule + +interface MainDispatcherTestRule : TestRule { + + fun advanceUntilIdleWithIdlingResources() + + fun advanceUntilIdleOrActivityIsDestroyed() + + fun advanceUntilIdle() + + fun advanceTimeBy(delayInMillis: Long) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/ServerTypeConfiguration.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/ServerTypeConfiguration.kt new file mode 100644 index 0000000..26752aa --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/ServerTypeConfiguration.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup + +interface ServerTypeConfiguration { + + val useHttps: Boolean + + val url: String + + fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackBarTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackBarTestRule.kt new file mode 100644 index 0000000..1b2987a --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackBarTestRule.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.junit.rules.TestRule + +interface SnackBarTestRule : TestRule diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationTestRule.kt new file mode 100644 index 0000000..fa89428 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationTestRule.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.testutils.configuration + +import androidx.annotation.StringRes +import org.junit.rules.TestRule + +interface SnackbarVerificationTestRule : TestRule { + + fun assertIsShownWithText(@StringRes stringResID: Int) + + fun assertIsNotShown() +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt new file mode 100644 index 0000000..af3c0d5 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.testutils.configuration + +/** + * Defines the platform specific configurations for Robolectric and AndroidTest. + * + * Each should have an object [SpecificTestConfigurationsFactory] implementing this interface so the SharedTests are + * configured properly. + */ +interface TestConfigurationsFactory { + + fun createMainDispatcherTestRule(): MainDispatcherTestRule + + fun createServerTypeConfiguration(): ServerTypeConfiguration + + fun createLoginRobotConfiguration(): LoginRobotConfiguration + + fun createSnackbarVerification(): SnackbarVerificationTestRule +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/doBlockinglyOnMainThread.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/doBlockinglyOnMainThread.kt new file mode 100644 index 0000000..f9aa3bd --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/doBlockinglyOnMainThread.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking + +fun doBlockinglyOnMainThread(action: () -> Unit) { + if (Looper.myLooper() === Looper.getMainLooper()) { + action() + } else { + val deferred = CompletableDeferred() + Handler(Looper.getMainLooper()).post { + action() + deferred.complete(Unit) + } + runBlocking { deferred.await() } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/CompositeDisposable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/CompositeDisposable.kt new file mode 100644 index 0000000..a3fbda4 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/CompositeDisposable.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils.idling + +class CompositeDisposable(disposable: List = emptyList()) : Disposable { + + constructor(vararg disposables: Disposable) : this(disposables.toList()) + + private val disposables = disposable.toMutableList() + override val isDisposed: Boolean get() = disposables.all(Disposable::isDisposed) + + fun add(disposable: Disposable) { + disposables.add(disposable) + } + + override fun dispose() { + disposables.forEach { + it.dispose() + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/Disposable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/Disposable.kt new file mode 100644 index 0000000..56a3e04 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/Disposable.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.testutils.idling + +interface Disposable { + val isDisposed: Boolean + fun dispose() +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/IdlingResourceDisposable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/IdlingResourceDisposable.kt new file mode 100644 index 0000000..df5e2f3 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/IdlingResourceDisposable.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils.idling + +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource + +internal class IdlingResourceDisposable(private val idlingResource: IdlingResource) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + IdlingRegistry.getInstance().register(idlingResource) + } + + override fun dispose() { + if (isDisposed) return + isDisposed = true + IdlingRegistry.getInstance().unregister(idlingResource) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronization.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronization.kt new file mode 100644 index 0000000..a6fca71 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronization.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.testutils.idling + +import androidx.annotation.CheckResult +import androidx.test.espresso.IdlingResource +import com.jakewharton.espresso.OkHttp3IdlingResource +import okhttp3.OkHttpClient +import org.koin.core.qualifier.StringQualifier +import org.koin.test.KoinTest +import org.koin.test.get + +object NetworkSynchronization : KoinTest { + + @CheckResult + fun registerNetworkingSynchronization(): Disposable { + val idlingResources = OkHttpClientTypes.values() + .map { it to getOkHttpClient(it) } + .associateBy { it.second.dispatcher } + .values + .map { (key, client) -> client.asIdlingResource(key.qualifier) } + .map(::IdlingResourceDisposable) + + return CompositeDisposable(idlingResources) + } + + private fun getOkHttpClient(type: OkHttpClientTypes): OkHttpClient = get(StringQualifier(type.qualifier)) + + 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/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt new file mode 100644 index 0000000..896255a --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt @@ -0,0 +1,68 @@ +package org.fnives.test.showcase.testutils.idling + +import androidx.test.espresso.Espresso +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource +import androidx.test.espresso.matcher.ViewMatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineDispatcher +import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadFor +import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadUntilIdle + +private val idleScope = CoroutineScope(Dispatchers.IO) + +// workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 +fun anyResourceIdling(): Boolean = !IdlingRegistry.getInstance().resources.all(IdlingResource::isIdleNow) + +fun awaitIdlingResources() { + val idlingRegistry = IdlingRegistry.getInstance() + if (idlingRegistry.resources.all(IdlingResource::isIdleNow)) return + + var isIdle = false + idleScope.launch { + do { + idlingRegistry.resources + .filterNot(IdlingResource::isIdleNow) + .forEach { idlingRegistry -> + idlingRegistry.awaitUntilIdle() + } + } while (!idlingRegistry.resources.all(IdlingResource::isIdleNow)) + isIdle = true + } + while (!isIdle) { + loopMainThreadFor(200L) + } +} + +private suspend fun IdlingResource.awaitUntilIdle() { + // using loop because some times, registerIdleTransitionCallback wasn't called + while (true) { + if (isIdleNow) return + delay(100) + } +} + +fun TestCoroutineDispatcher.advanceUntilIdleWithIdlingResources() { + advanceUntilIdle() // advance until a request is sent + while (anyResourceIdling()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + advanceUntilIdle() // run coroutines after request is finished + } + advanceUntilIdle() +} + +fun loopMainThreadUntilIdleWithIdlingResources() { + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // advance until a request is sent + while (anyResourceIdling()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // run coroutines after request is finished + } + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) +} + +fun loopMainThreadFor(delay: Long) { + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay)) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt new file mode 100644 index 0000000..c393b4b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.testutils.robot + +interface Robot { + + fun init() + + fun release() +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt new file mode 100644 index 0000000..e54716b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.testutils.robot + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class RobotTestRule(val robot: T) : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + robot.init() + try { + base.evaluate() + } finally { + robot.release() + } + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupLoggedInState.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupLoggedInState.kt new file mode 100644 index 0000000..4e978ee --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupLoggedInState.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.testutils.statesetup + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.core.login.LoginUseCase +import org.fnives.test.showcase.core.login.LogoutUseCase +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.koin.test.KoinTest +import org.koin.test.get + +object SetupLoggedInState : KoinTest { + + private val logoutUseCase get() = get() + private val loginUseCase get() = get() + private val isUserLoggedInUseCase get() = get() + + fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) { + mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b")) + runBlocking { + loginUseCase.invoke(LoginCredentials("a", "b")) + } + } + + fun isLoggedIn() = isUserLoggedInUseCase.invoke() + + fun setupLogout() { + runBlocking { logoutUseCase.invoke() } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/LoopMainThreadFor.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/LoopMainThreadFor.kt new file mode 100644 index 0000000..7fe7451 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/LoopMainThreadFor.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.view.View +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import org.hamcrest.Matcher +import org.hamcrest.Matchers + +class LoopMainThreadFor(private val delayInMillis: Long) : ViewAction { + override fun getConstraints(): Matcher = Matchers.isA(View::class.java) + + override fun getDescription(): String = "loop MainThread for $delayInMillis milliseconds" + + override fun perform(uiController: UiController, view: View?) { + uiController.loopMainThreadForAtLeast(delayInMillis) + } +} + +class LoopMainThreadUntilIdle : ViewAction { + override fun getConstraints(): Matcher = Matchers.isA(View::class.java) + + override fun getDescription(): String = "loop MainThread for until Idle" + + override fun perform(uiController: UiController, view: View?) { + uiController.loopMainThreadUntilIdle() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/PullToRefresh.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/PullToRefresh.kt new file mode 100644 index 0000000..39f9c85 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/PullToRefresh.kt @@ -0,0 +1,44 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.view.View +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.swiperefreshlayout.widget.listener +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import org.fnives.test.showcase.testutils.doBlockinglyOnMainThread +import org.hamcrest.BaseMatcher +import org.hamcrest.CoreMatchers.isA +import org.hamcrest.Description +import org.hamcrest.Matcher + +// swipe-refresh-layout swipe-down doesn't work, inspired by https://github.com/robolectric/robolectric/issues/5375 +class PullToRefresh : ViewAction { + + override fun getConstraints(): Matcher { + return object : BaseMatcher() { + override fun matches(item: Any): Boolean { + return isA(SwipeRefreshLayout::class.java).matches(item) + } + + override fun describeMismatch(item: Any, mismatchDescription: Description) { + mismatchDescription.appendText("Expected SwipeRefreshLayout or its Descendant, but got other View") + } + + override fun describeTo(description: Description) { + description.appendText("Action SwipeToRefresh to view SwipeRefreshLayout or its descendant") + } + } + } + + override fun getDescription(): String { + return "Perform pull-to-refresh on the SwipeRefreshLayout" + } + + override fun perform(uiController: UiController, view: View) { + val swipeRefreshLayout = view as SwipeRefreshLayout + doBlockinglyOnMainThread { + swipeRefreshLayout.isRefreshing = true + swipeRefreshLayout.listener().onRefresh() + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/SwipeRefreshLayoutExtension.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/SwipeRefreshLayoutExtension.kt new file mode 100644 index 0000000..edf9edc --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/SwipeRefreshLayoutExtension.kt @@ -0,0 +1,5 @@ +@file:Suppress("PackageDirectoryMismatch") + +package androidx.swiperefreshlayout.widget + +fun SwipeRefreshLayout.listener() = mListener diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/WithDrawable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/WithDrawable.kt new file mode 100644 index 0000000..79287c1 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/WithDrawable.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.view.View +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher + +class WithDrawable( + @DrawableRes + private val id: Int, + @ColorRes + private val tint: Int? = null, + private val tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN +) : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("ImageView with drawable same as drawable with id $id") + tint?.let { description.appendText(", tint color id: $tint, mode: $tintMode") } + } + + override fun matchesSafely(view: View): Boolean { + val context = view.context + val tintColor = tint?.let { ContextCompat.getColor(view.context, it) } + val expectedBitmap = context.getDrawable(id)?.apply { + if (tintColor != null) { + setTintList(ColorStateList.valueOf(tintColor)) + setTintMode(tintMode) + } + } + return view is ImageView && view.drawable.toBitmap().sameAs(expectedBitmap?.toBitmap()) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/notIntended.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/notIntended.kt new file mode 100644 index 0000000..a88cf48 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/notIntended.kt @@ -0,0 +1,17 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.content.Intent +import androidx.test.espresso.intent.Intents.intended +import org.hamcrest.Matcher +import org.hamcrest.StringDescription + +fun notIntended(matcher: Matcher) { + try { + intended(matcher) + } catch (assertionError: AssertionError) { + return + } + val description = StringDescription() + matcher.describeMismatch(null, description) + throw IllegalStateException("Navigate to intent found matching $description") +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt new file mode 100644 index 0000000..4f6dbfa --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt @@ -0,0 +1,95 @@ +package org.fnives.test.showcase.ui.home + +import android.app.Instrumentation +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +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.R +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.testutils.robot.Robot +import org.fnives.test.showcase.testutils.viewactions.PullToRefresh +import org.fnives.test.showcase.testutils.viewactions.WithDrawable +import org.fnives.test.showcase.testutils.viewactions.notIntended +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.hamcrest.Matchers.allOf + +class HomeRobot : Robot { + + override fun init() { + Intents.init() + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(0, null)) + } + + override fun release() { + Intents.release() + } + + fun assertNavigatedToAuth() = apply { + Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun assertDidNotNavigateToAuth() = apply { + notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun clickSignOut() = apply { + Espresso.onView(withId(R.id.logout_cta)).perform(click()) + } + + fun assertContainsItem(item: FavouriteContent) = apply { + val isFavouriteResourceId = if (item.isFavourite) { + R.drawable.favorite_24 + } else { + R.drawable.favorite_border_24 + } + 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(item: Content) = apply { + 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 { + 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/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityTest.kt new file mode 100644 index 0000000..b2c9572 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityTest.kt @@ -0,0 +1,239 @@ +package org.fnives.test.showcase.ui.home + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.model.content.FavouriteContent +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.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory +import org.fnives.test.showcase.testutils.idling.Disposable +import org.fnives.test.showcase.testutils.idling.NetworkSynchronization +import org.fnives.test.showcase.testutils.idling.loopMainThreadFor +import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources +import org.fnives.test.showcase.testutils.robot.RobotTestRule +import org.fnives.test.showcase.testutils.statesetup.SetupLoggedInState +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.test.KoinTest + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +class MainActivityTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + + @Rule + @JvmField + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Rule + @JvmField + val snackbarVerificationTestRule = SpecificTestConfigurationsFactory.createSnackbarVerification() + + @Rule + @JvmField + val robotRule = RobotTestRule(HomeRobot()) + private val homeRobot get() = robotRule.robot + + @Rule + @JvmField + val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + + @Rule + @JvmField + val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule() + + @Rule + @JvmField + val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule() + + private lateinit var disposable: Disposable + + @Before + fun setUp() { + SpecificTestConfigurationsFactory.createServerTypeConfiguration() + .invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + + SetupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + disposable = NetworkSynchronization.registerNetworkingSynchronization() + } + + @After + fun tearDown() { + activityScenario.moveToState(Lifecycle.State.DESTROYED) + disposable.dispose() + } + + @Test + fun GIVEN_initialized_MainActivity_WHEN_signout_is_clicked_THEN_user_is_signed_out() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario(ContentScenario.Error(false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + homeRobot.clickSignOut() + mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed() + + homeRobot.assertNavigatedToAuth() + Assert.assertEquals(false, SetupLoggedInState.isLoggedIn()) + } + + @Test + fun GIVEN_success_response_WHEN_data_is_returned_THEN_it_is_shown_on_the_ui() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario(ContentScenario.Success(false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + ContentData.contentSuccess.forEach { + homeRobot.assertContainsItem(FavouriteContent(it, false)) + } + homeRobot.assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_success_response_WHEN_item_is_clicked_THEN_ui_is_updated() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario(ContentScenario.Success(false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + homeRobot.clickOnContentItem(ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true) + homeRobot.assertContainsItem(expectedItem) + .assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_success_response_WHEN_item_is_clicked_THEN_ui_is_updated_even_if_activity_is_recreated() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario(ContentScenario.Success(false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + homeRobot.clickOnContentItem(ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true) + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + homeRobot.assertContainsItem(expectedItem) + .assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_success_response_WHEN_item_is_clicked_then_clicked_again_THEN_ui_is_updated() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario(ContentScenario.Success(false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + homeRobot.clickOnContentItem(ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + homeRobot.clickOnContentItem(ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false) + homeRobot.assertContainsItem(expectedItem) + .assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_error_response_WHEN_loaded_THEN_error_is_Shown() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario(ContentScenario.Error(false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + homeRobot.assertContainsNoItems() + .assertContainsError() + .assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_error_response_then_success_WHEN_retried_THEN_success_is_shown() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario( + ContentScenario.Error(false) + .then(ContentScenario.Success(false)) + ) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + homeRobot.swipeRefresh() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loopMainThreadFor(2000L) + + ContentData.contentSuccess.forEach { + homeRobot.assertContainsItem(FavouriteContent(it, false)) + } + homeRobot.assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_success_then_error_WHEN_retried_THEN_error_is_shown() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario( + ContentScenario.Success(false) + .then(ContentScenario.Error(false)) + ) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + homeRobot.swipeRefresh() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loopMainThreadUntilIdleWithIdlingResources() + mainDispatcherTestRule.advanceTimeBy(1000L) + loopMainThreadFor(1000) + + homeRobot + .assertContainsError() + .assertContainsNoItems() + .assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_unauthenticated_then_success_WHEN_loaded_THEN_success_is_shown() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario( + ContentScenario.Unauthorized(false) + .then(ContentScenario.Success(true)) + ) + .setScenario(RefreshTokenScenario.Success) + + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + ContentData.contentSuccess.forEach { + homeRobot.assertContainsItem(FavouriteContent(it, false)) + } + homeRobot.assertDidNotNavigateToAuth() + } + + @Test + fun GIVEN_unauthenticated_then_error_WHEN_loaded_THEN_navigated_to_auth() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup + .setScenario(ContentScenario.Unauthorized(false)) + .setScenario(RefreshTokenScenario.Error) + + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + homeRobot.assertNavigatedToAuth() + Assert.assertEquals(false, SetupLoggedInState.isLoggedIn()) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityTest.kt new file mode 100644 index 0000000..e971de2 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityTest.kt @@ -0,0 +1,159 @@ +package org.fnives.test.showcase.ui.login + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.R +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory +import org.fnives.test.showcase.testutils.idling.Disposable +import org.fnives.test.showcase.testutils.idling.NetworkSynchronization +import org.fnives.test.showcase.testutils.robot.RobotTestRule +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.test.KoinTest + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +class AuthActivityTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + + @Rule + @JvmField + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Rule + @JvmField + val snackbarVerificationTestRule = SpecificTestConfigurationsFactory.createSnackbarVerification() + + @Rule + @JvmField + val robotRule = RobotTestRule(LoginRobot()) + private val loginRobot get() = robotRule.robot + + @Rule + @JvmField + val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + + @Rule + @JvmField + val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule() + + @Rule + @JvmField + val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule() + + private lateinit var disposable: Disposable + + @Before + fun setUp() { + SpecificTestConfigurationsFactory.createServerTypeConfiguration() + .invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + disposable = NetworkSynchronization.registerNetworkingSynchronization() + } + + @After + fun tearDown() { + activityScenario.moveToState(Lifecycle.State.DESTROYED) + disposable.dispose() + } + + @Test + fun GIVEN_non_empty_password_and_username_and_successful_response_WHEN_signIn_THEN_no_error_is_shown_and_navigating_to_home() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario( + AuthScenario.Success( + password = "alma", + username = "banan" + ) + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + loginRobot + .setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed() + loginRobot.assertNavigatedToHome() + } + + @Test + fun GIVEN_empty_password_and_username_WHEN_signIn_THEN_error_password_is_shown() { + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + loginRobot + .setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loginRobot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + @Test + fun GIVEN_password_and_empty_username_WHEN_signIn_THEN_error_username_is_shown() { + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + loginRobot + .setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loginRobot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + @Test + fun GIVEN_password_and_username_and_invalid_credentials_response_WHEN_signIn_THEN_error_invalid_credentials_is_shown() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + loginRobot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loginRobot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + @Test + fun GIVEN_password_and_username_and_error_response_WHEN_signIn_THEN_error_invalid_credentials_is_shown() { + mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + loginRobot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loginRobot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt new file mode 100644 index 0000000..f291677 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt @@ -0,0 +1,100 @@ +package org.fnives.test.showcase.ui.login + +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.Intents.intending +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.R +import org.fnives.test.showcase.testutils.configuration.LoginRobotConfiguration +import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationTestRule +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory +import org.fnives.test.showcase.testutils.configuration.TestConfigurationsFactory +import org.fnives.test.showcase.testutils.robot.Robot +import org.fnives.test.showcase.testutils.viewactions.notIntended +import org.fnives.test.showcase.ui.home.MainActivity +import org.hamcrest.core.IsNot.not + +class LoginRobot( + private val loginRobotConfiguration: LoginRobotConfiguration, + private val snackbarVerificationTestRule: SnackbarVerificationTestRule +) : Robot { + + constructor(testConfigurationsFactory: TestConfigurationsFactory = SpecificTestConfigurationsFactory) : + this( + loginRobotConfiguration = testConfigurationsFactory.createLoginRobotConfiguration(), + snackbarVerificationTestRule = testConfigurationsFactory.createSnackbarVerification() + ) + + override fun init() { + Intents.init() + intending(hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + override fun release() { + Intents.release() + } + + 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 { + 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 { + snackbarVerificationTestRule.assertIsShownWithText(stringResID) + } + + fun assertLoadingBeforeRequests() = apply { + if (loginRobotConfiguration.assertLoadingBeforeRequest) { + 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 { + snackbarVerificationTestRule.assertIsNotShown() + } + + fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt new file mode 100644 index 0000000..ea8c92b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt @@ -0,0 +1,89 @@ +package org.fnives.test.showcase.ui.splash + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory +import org.fnives.test.showcase.testutils.idling.Disposable +import org.fnives.test.showcase.testutils.idling.NetworkSynchronization +import org.fnives.test.showcase.testutils.robot.RobotTestRule +import org.fnives.test.showcase.testutils.statesetup.SetupLoggedInState +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.test.KoinTest + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +class SplashActivityTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + + private val splashRobot: SplashRobot get() = robotTestRule.robot + + @Rule + @JvmField + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Rule + @JvmField + val robotTestRule = RobotTestRule(SplashRobot()) + + @Rule + @JvmField + val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule() + + @Rule + @JvmField + val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + + @Rule + @JvmField + val reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule() + + lateinit var disposable: Disposable + + @Before + fun setUp() { + SpecificTestConfigurationsFactory.createServerTypeConfiguration() + .invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + disposable = NetworkSynchronization.registerNetworkingSynchronization() + } + + @After + fun tearDown() { + activityScenario.moveToState(Lifecycle.State.DESTROYED) + disposable.dispose() + } + + @Test + fun GIVEN_loggedInState_WHEN_opened_THEN_MainActivity_is_started() { + SetupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + + mainDispatcherTestRule.advanceTimeBy(500) + + splashRobot.assertHomeIsStarted() + .assertAuthIsNotStarted() + + SetupLoggedInState.setupLogout() + } + + @Test + fun GIVEN_loggedOffState_WHEN_opened_THEN_AuthActivity_is_started() { + SetupLoggedInState.setupLogout() + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + + mainDispatcherTestRule.advanceTimeBy(500) + + splashRobot.assertAuthIsStarted() + .assertHomeIsNotStarted() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt new file mode 100644 index 0000000..ae89da1 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt @@ -0,0 +1,40 @@ +package org.fnives.test.showcase.ui.splash + +import android.app.Instrumentation +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import org.fnives.test.showcase.testutils.robot.Robot +import org.fnives.test.showcase.testutils.viewactions.notIntended +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.fnives.test.showcase.ui.home.MainActivity + +class SplashRobot : Robot { + + override fun init() { + Intents.init() + Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(0, null)) + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(0, null)) + } + + override fun release() { + Intents.release() + } + + 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/app/src/test/java/org/fnives/test/showcase/di/DITest.kt b/app/src/test/java/org/fnives/test/showcase/di/DITest.kt new file mode 100644 index 0000000..1a88d3d --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/di/DITest.kt @@ -0,0 +1,63 @@ +package org.fnives.test.showcase.di + +import android.content.Context +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.fnives.test.showcase.ui.auth.AuthViewModel +import org.fnives.test.showcase.ui.home.MainViewModel +import org.fnives.test.showcase.ui.splash.SplashViewModel +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.check.checkModules +import org.koin.test.inject +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@ExtendWith(TestMainDispatcher::class) +class DITest : KoinTest { + + private val authViewModel by inject() + private val mainViewModel by inject() + private val splashViewModel by inject() + + @BeforeEach + fun setUp() { + TestMainDispatcher.testDispatcher.pauseDispatcher() + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun verifyStaticModules() { + val mockContext = mock() + whenever(mockContext.getSharedPreferences(anyOrNull(), anyOrNull())).doReturn(mock()) + checkModules { + androidContext(mockContext) + modules(createAppModules(BaseUrl("https://a.com/"))) + } + } + + @Test + fun verifyViewModelModules() { + val mockContext = mock() + whenever(mockContext.getSharedPreferences(anyOrNull(), anyOrNull())).doReturn(mock()) + startKoin { + androidContext(mockContext) + modules(createAppModules(BaseUrl("https://a.com/"))) + } + authViewModel + mainViewModel + splashViewModel + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/testutils/InstantExecutorExtension.kt b/app/src/test/java/org/fnives/test/showcase/testutils/InstantExecutorExtension.kt new file mode 100644 index 0000000..bfd0594 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/testutils/InstantExecutorExtension.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.testutils + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance() + .setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt new file mode 100644 index 0000000..3915734 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.testutils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class TestMainDispatcher : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + val testDispatcher = TestCoroutineDispatcher() + privateTestDispatcher = testDispatcher + DatabaseInitialization.dispatcher = testDispatcher + Dispatchers.setMain(testDispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + privateTestDispatcher = null + } + + companion object { + private var privateTestDispatcher: TestCoroutineDispatcher? = null + val testDispatcher: TestCoroutineDispatcher + get() = privateTestDispatcher ?: throw IllegalStateException("TestMainDispatcher is in afterEach State") + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt new file mode 100644 index 0000000..242a8e7 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt @@ -0,0 +1,190 @@ +package org.fnives.test.showcase.ui.auth + +import com.jraska.livedata.test +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.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 +import org.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.fnives.test.showcase.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +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, TestMainDispatcher::class) +internal class AuthViewModelTest { + + private lateinit var sut: AuthViewModel + private lateinit var mockLoginUseCase: LoginUseCase + private val testDispatcher get() = TestMainDispatcher.testDispatcher + + @BeforeEach + fun setUp() { + mockLoginUseCase = mock() + testDispatcher.pauseDispatcher() + sut = AuthViewModel(mockLoginUseCase) + } + + @Test + fun GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty() { + testDispatcher.resumeDispatcher() + sut.username.test().assertNoValue() + sut.password.test().assertNoValue() + sut.loading.test().assertValue(false) + sut.error.test().assertNoValue() + sut.navigateToHome.test().assertNoValue() + } + + @Test + fun GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated() { + testDispatcher.resumeDispatcher() + val passwordTestObserver = sut.password.test() + + sut.onPasswordChanged("a") + sut.onPasswordChanged("al") + + passwordTestObserver.assertValueHistory("a", "al") + sut.username.test().assertNoValue() + sut.loading.test().assertValue(false) + sut.error.test().assertNoValue() + sut.navigateToHome.test().assertNoValue() + } + + @Test + fun GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated() { + testDispatcher.resumeDispatcher() + val usernameTestObserver = sut.username.test() + + sut.onUsernameChanged("a") + sut.onUsernameChanged("al") + + usernameTestObserver.assertValueHistory("a", "al") + sut.password.test().assertNoValue() + sut.loading.test().assertValue(false) + sut.error.test().assertNoValue() + sut.navigateToHome.test().assertNoValue() + } + + @Test + fun GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { + val loadingTestObserver = sut.loading.test() + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @Test + fun WHEN_login_is_Called_twise_THEN_use_case_is_only_called_once() { + runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } + + sut.onLogin() + sut.onLogin() + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @Test + fun GIVEN_password_and_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + sut.onPasswordChanged("pass") + sut.onUsernameChanged("usr") + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + runBlocking { + verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("usr", "pass")) + } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @Test + fun GIVEN_answer_error_WHEN_login_called_THEN_error_is_shown() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + val loadingObserver = sut.loading.test() + val errorObserver = sut.error.test() + val navigateToHomeObserver = sut.navigateToHome.test() + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingObserver.assertValueHistory(false, true, false) + errorObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) + navigateToHomeObserver.assertNoValue() + } + + @MethodSource("loginErrorStatusesArguments") + @ParameterizedTest(name = "GIVEN_answer_success_loginStatus_{0}_WHEN_login_called_THEN_error_{1}_is_shown") + fun GIVEN_answer_success_invalid_loginStatus_WHEN_login_called_THEN_error_is_shown( + loginStatus: LoginStatus, + errorType: AuthViewModel.ErrorType + ) { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus)) + } + val loadingObserver = sut.loading.test() + val errorObserver = sut.error.test() + val navigateToHomeObserver = sut.navigateToHome.test() + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingObserver.assertValueHistory(false, true, false) + errorObserver.assertValueHistory(Event(errorType)) + navigateToHomeObserver.assertNoValue() + } + + @Test + fun GIVEN_answer_success_login_status_success_WHEN_login_called_THEN_navigation_event_is_sent() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS)) + } + val loadingObserver = sut.loading.test() + val errorObserver = sut.error.test() + val navigateToHomeObserver = sut.navigateToHome.test() + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingObserver.assertValueHistory(false, true, false) + errorObserver.assertNoValue() + navigateToHomeObserver.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/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt new file mode 100644 index 0000000..e6f7eb7 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt @@ -0,0 +1,243 @@ +package org.fnives.test.showcase.ui.home + +import com.jraska.livedata.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.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.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.junit.jupiter.api.BeforeEach +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.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::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 testDispatcher get() = TestMainDispatcher.testDispatcher + + @BeforeEach + fun setUp() { + mockGetAllContentUseCase = mock() + mockLogoutUseCase = mock() + mockFetchContentUseCase = mock() + mockAddContentToFavouriteUseCase = mock() + mockRemoveContentFromFavouritesUseCase = mock() + testDispatcher.pauseDispatcher() + sut = MainViewModel( + getAllContentUseCase = mockGetAllContentUseCase, + logoutUseCase = mockLogoutUseCase, + fetchContentUseCase = mockFetchContentUseCase, + addContentToFavouriteUseCase = mockAddContentToFavouriteUseCase, + removeContentFromFavouritesUseCase = mockRemoveContentFromFavouritesUseCase + ) + } + + @Test + fun WHEN_initialization_THEN_error_false_other_states_empty() { + sut.errorMessage.test().assertValue(false) + sut.content.test().assertNoValue() + sut.loading.test().assertNoValue() + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_initialized_viewModel_WHEN_loading_is_returned_THEN_loading_is_shown() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.errorMessage.test().assertValue(false) + sut.content.test().assertNoValue() + sut.loading.test().assertValue(true) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_then_data_WHEN_observing_content_THEN_proper_states_are_shown() { + 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() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, false, false) + contentTestObserver.assertValueHistory(listOf()) + loadingTestObserver.assertValueHistory(true, false) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_then_error_WHEN_observing_content_THEN_proper_states_are_shown() { + 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() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, false, true) + contentTestObserver.assertValueHistory(emptyList()) + loadingTestObserver.assertValueHistory(true, false) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_then_error_then_loading_then_data_WHEN_observing_content_THEN_proper_states_are_shown() { + 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() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, false, true, false, false) + contentTestObserver.assertValueHistory(emptyList(), content) + loadingTestObserver.assertValueHistory(true, false, true, false) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_viewModel_WHEN_refreshing_THEN_usecase_is_not_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onRefresh() + testDispatcher.advanceUntilIdle() + + verifyZeroInteractions(mockFetchContentUseCase) + } + + @Test + fun GIVEN_non_loading_viewModel_WHEN_refreshing_THEN_usecase_is_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onRefresh() + testDispatcher.advanceUntilIdle() + + verify(mockFetchContentUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockFetchContentUseCase) + } + + @Test + fun GIVEN_loading_viewModel_WHEN_loging_out_THEN_usecase_is_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onLogout() + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @Test + fun GIVEN_non_loading_viewModel_WHEN_loging_out_THEN_usecase_is_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onLogout() + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @Test + fun GIVEN_success_content_list_viewModel_WHEN_toggling_a_nonexistent_contentId_THEN_nothing_happens() { + 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() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("c")) + testDispatcher.advanceUntilIdle() + + verifyZeroInteractions(mockRemoveContentFromFavouritesUseCase) + verifyZeroInteractions(mockAddContentToFavouriteUseCase) + } + + @Test + fun GIVEN_success_content_list_viewModel_WHEN_toggling_a_favourite_contentId_THEN_remove_favourite_usecase_is_called() { + 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() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("b")) + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockRemoveContentFromFavouritesUseCase, times(1)).invoke(ContentId("b")) } + verifyNoMoreInteractions(mockRemoveContentFromFavouritesUseCase) + verifyZeroInteractions(mockAddContentToFavouriteUseCase) + } + + @Test + fun GIVEN_success_content_list_viewModel_WHEN_toggling_a_not_favourite_contentId_THEN_add_favourite_usecase_is_called() { + 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() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("a")) + testDispatcher.advanceUntilIdle() + + verifyZeroInteractions(mockRemoveContentFromFavouritesUseCase) + runBlocking { verify(mockAddContentToFavouriteUseCase, times(1)).invoke(ContentId("a")) } + verifyNoMoreInteractions(mockAddContentToFavouriteUseCase) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/ui/shared/EventTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/shared/EventTest.kt new file mode 100644 index 0000000..1c1043b --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/shared/EventTest.kt @@ -0,0 +1,48 @@ +package org.fnives.test.showcase.ui.shared + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +internal class EventTest { + + @Test + fun GIVEN_event_WHEN_consumed_is_called_THEN_value_is_returned() { + val expected = "a" + + val actual = Event("a").consume() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_consumed_event_WHEN_consumed_is_called_THEN_null_is_returned() { + val expected: String? = null + val event = Event("a") + event.consume() + + val actual = event.consume() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_event_WHEN_peek_is_called_THEN_value_is_returned() { + val expected = "a" + + val actual = Event("a").peek() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_consumed_event_WHEN_peek_is_called_THEN_value_is_returned() { + val expected = "a" + val event = Event("a") + event.consume() + + val actual = event.peek() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt new file mode 100644 index 0000000..dfcb5e9 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.ui.splash + +import com.jraska.livedata.test +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.fnives.test.showcase.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +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 + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +internal class SplashViewModelTest { + + private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase + private lateinit var sut: SplashViewModel + private val testCoroutineDispatcher get() = TestMainDispatcher.testDispatcher + + @BeforeEach + fun setUp() { + mockIsUserLoggedInUseCase = mock() + sut = SplashViewModel(mockIsUserLoggedInUseCase) + } + + @Test + fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_authentication() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + + testCoroutineDispatcher.advanceTimeBy(500) + + sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) + } + + @Test + fun GIVEN_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_home() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) + + testCoroutineDispatcher.advanceTimeBy(500) + + sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.HOME)) + } + + @Test + fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_before_half_a_second_no_event_is_sent() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + + testCoroutineDispatcher.advanceTimeBy(100) + + sut.navigateTo.test().assertNoValue() + } +} diff --git a/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/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/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..0b813b6 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +sdk=28 +shadows=org.fnives.test.showcase.testutils.shadow.ShadowSnackbar diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..f6cdc86 --- /dev/null +++ b/build.gradle @@ -0,0 +1,63 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.kotlin_version = "1.4.32" + ext.detekt_version = "1.16.0" + repositories { + google() + maven { url "https://plugins.gradle.org/m2/" } + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.1.3" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jlleitschuh.gradle:ktlint-gradle:10.0.0" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +//apply plugin: "io.gitlab.arturbosch.detekt" version "$detekt_version" +plugins { + id "io.gitlab.arturbosch.detekt" version "$detekt_version" +} +detekt { + toolVersion = "$detekt_version" + + input = files( + "$projectDir/app/src/main/java", + "$projectDir/core/src/main/java", + "$projectDir/mockserver/src/main/java", + "$projectDir/model/src/main/java", + "$projectDir/network/src/main/java" + ) + config = files("$projectDir/detekt/detekt.yml") + baseline = file("$projectDir/detekt/baseline.xml") + reports { + txt { + enabled = true + destination = file("build/reports/detekt.txt") + } + html { + enabled = true + destination = file("build/reports/detekt.html") + } + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +subprojects { + apply plugin: "org.jlleitschuh.gradle.ktlint" +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + +apply from: 'gradlescripts/versions.gradle' \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..1e5acf0 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'java-library' + id 'kotlin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +test { + useJUnitPlatform() + testLogging { + events 'started', 'passed', 'skipped', 'failed' + exceptionFormat "full" + showStandardStreams true + } +} + +dependencies { + api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + api project(":model") + implementation project(":network") + + testImplementation "org.koin:koin-test:$koin_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version" + testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" +} \ No newline at end of file diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCase.kt new file mode 100644 index 0000000..c5ea010 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCase.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.core.content + +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class AddContentToFavouriteUseCase internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage +) { + + suspend fun invoke(contentId: ContentId) = + favouriteContentLocalStorage.markAsFavourite(contentId) +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/ContentRepository.kt b/core/src/main/java/org/fnives/test/showcase/core/content/ContentRepository.kt new file mode 100644 index 0000000..519110d --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/ContentRepository.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.core.content + +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.core.shared.Optional +import org.fnives.test.showcase.core.shared.mapIntoResource +import org.fnives.test.showcase.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.content.ContentRemoteSource + +internal class ContentRepository(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) + } + 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/core/src/main/java/org/fnives/test/showcase/core/content/FetchContentUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/FetchContentUseCase.kt new file mode 100644 index 0000000..90c7a5b --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/FetchContentUseCase.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.core.content + +class FetchContentUseCase internal constructor(private val contentRepository: ContentRepository) { + + fun invoke() = contentRepository.fetch() +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt new file mode 100644 index 0000000..d078d68 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.fnives.test.showcase.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 + +class GetAllContentUseCase internal constructor( + private val contentRepository: ContentRepository, + private val favouriteContentLocalStorage: FavouriteContentLocalStorage +) { + + fun get(): Flow>> = + contentRepository.contents.combine(favouriteContentLocalStorage.observeFavourites(), ::combineContentWithFavourites) + + 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/core/src/main/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCase.kt new file mode 100644 index 0000000..494af3e --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCase.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.core.content + +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class RemoveContentFromFavouritesUseCase internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage +) { + + suspend fun invoke(contentId: ContentId) { + favouriteContentLocalStorage.deleteAsFavourite(contentId) + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/di/createCoreModule.kt b/core/src/main/java/org/fnives/test/showcase/core/di/createCoreModule.kt new file mode 100644 index 0000000..a2b18d1 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/di/createCoreModule.kt @@ -0,0 +1,60 @@ +package org.fnives.test.showcase.core.di + +import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.core.content.ContentRepository +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.core.login.LoginUseCase +import org.fnives.test.showcase.core.login.LogoutUseCase +import org.fnives.test.showcase.core.session.SessionExpirationAdapter +import org.fnives.test.showcase.core.session.SessionExpirationListener +import org.fnives.test.showcase.core.storage.NetworkSessionLocalStorageAdapter +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.di.createNetworkModules +import org.koin.core.module.Module +import org.koin.core.scope.Scope +import org.koin.dsl.module + +fun createCoreModule( + baseUrl: BaseUrl, + enableNetworkLogging: Boolean, + userDataLocalStorageProvider: Scope.() -> UserDataLocalStorage, + sessionExpirationListenerProvider: Scope.() -> SessionExpirationListener, + favouriteContentLocalStorageProvider: Scope.() -> FavouriteContentLocalStorage +): Sequence = + createNetworkModules( + baseUrl = baseUrl, + enableLogging = enableNetworkLogging, + networkSessionLocalStorageProvider = { get() }, + networkSessionExpirationListenerProvider = { SessionExpirationAdapter(sessionExpirationListenerProvider()) } + ) + .plus(useCaseModule()) + .plus(storageModule(userDataLocalStorageProvider, favouriteContentLocalStorageProvider)) + .plus(repositoryModule()) + +fun repositoryModule() = module { + single(override = true) { ContentRepository(get()) } +} + +fun useCaseModule() = module { + factory { LoginUseCase(get(), get()) } + factory { LogoutUseCase(get()) } + factory { GetAllContentUseCase(get(), get()) } + factory { AddContentToFavouriteUseCase(get()) } + factory { RemoveContentFromFavouritesUseCase(get()) } + factory { IsUserLoggedInUseCase(get()) } + factory { FetchContentUseCase(get()) } +} + +fun storageModule( + userDataLocalStorageProvider: Scope.() -> UserDataLocalStorage, + favouriteContentLocalStorageProvider: Scope.() -> FavouriteContentLocalStorage +) = module { + single { userDataLocalStorageProvider() } + single { favouriteContentLocalStorageProvider() } + factory { NetworkSessionLocalStorageAdapter(get()) } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCase.kt new file mode 100644 index 0000000..fd0539a --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCase.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.core.storage.UserDataLocalStorage + +class IsUserLoggedInUseCase(private val userDataLocalStorage: UserDataLocalStorage) { + + fun invoke(): Boolean = userDataLocalStorage.session != null +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/login/LoginUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/login/LoginUseCase.kt new file mode 100644 index 0000000..2a91780 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/login/LoginUseCase.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +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.fnives.test.showcase.network.auth.LoginRemoteSource +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses + +class LoginUseCase 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/core/src/main/java/org/fnives/test/showcase/core/login/LogoutUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/login/LogoutUseCase.kt new file mode 100644 index 0000000..158e7e0 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/login/LogoutUseCase.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.core.di.repositoryModule +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.koin.core.context.loadKoinModules + +class LogoutUseCase(private val storage: UserDataLocalStorage) { + + suspend fun invoke() { + loadKoinModules(repositoryModule()) + storage.session = null + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationAdapter.kt b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationAdapter.kt new file mode 100644 index 0000000..1947635 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationAdapter.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.core.session + +import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener + +internal class SessionExpirationAdapter( + private val sessionExpirationListener: SessionExpirationListener +) : + NetworkSessionExpirationListener { + + override fun onSessionExpired() = sessionExpirationListener.onSessionExpired() +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationListener.kt b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationListener.kt new file mode 100644 index 0000000..3984a2b --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationListener.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.core.session + +interface SessionExpirationListener { + fun onSessionExpired() +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/shared/AnswerUtils.kt b/core/src/main/java/org/fnives/test/showcase/core/shared/AnswerUtils.kt new file mode 100644 index 0000000..cf7bad0 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/shared/AnswerUtils.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.core.shared + +import kotlinx.coroutines.CancellationException +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException + +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/core/src/main/java/org/fnives/test/showcase/core/shared/Optional.kt b/core/src/main/java/org/fnives/test/showcase/core/shared/Optional.kt new file mode 100644 index 0000000..939d91b --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/shared/Optional.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.core.shared + +internal class Optional(val item: T?) diff --git a/core/src/main/java/org/fnives/test/showcase/core/shared/UnexpectedException.kt b/core/src/main/java/org/fnives/test/showcase/core/shared/UnexpectedException.kt new file mode 100644 index 0000000..2c7a74d --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/shared/UnexpectedException.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.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/core/src/main/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapter.kt b/core/src/main/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapter.kt new file mode 100644 index 0000000..a9d5d77 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapter.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.core.storage + +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage + +internal class NetworkSessionLocalStorageAdapter( + private val userDataLocalStorage: UserDataLocalStorage +) : NetworkSessionLocalStorage { + + override var session: Session? + get() = userDataLocalStorage.session + set(value) { + userDataLocalStorage.session = value + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/storage/UserDataLocalStorage.kt b/core/src/main/java/org/fnives/test/showcase/core/storage/UserDataLocalStorage.kt new file mode 100644 index 0000000..919c00f --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/storage/UserDataLocalStorage.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.core.storage + +import org.fnives.test.showcase.model.session.Session + +interface UserDataLocalStorage { + var session: Session? +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/storage/content/FavouriteContentLocalStorage.kt b/core/src/main/java/org/fnives/test/showcase/core/storage/content/FavouriteContentLocalStorage.kt new file mode 100644 index 0000000..c778e45 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/storage/content/FavouriteContentLocalStorage.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.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/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt new file mode 100644 index 0000000..f146004 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.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.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.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class AddContentToFavouriteUseCaseTest { + + private lateinit var sut: AddContentToFavouriteUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = AddContentToFavouriteUseCase(mockFavouriteContentLocalStorage) + } + + @Test + fun WHEN_nothing_happens_THEN_the_storage_is_not_touched() { + verifyZeroInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_contentId_WHEN_called_THEN_storage_is_called() = runBlockingTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).markAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_throwing_local_storage_WHEN_thrown_THEN_its_thrown() = runBlockingTest { + whenever(mockFavouriteContentLocalStorage.markAsFavourite(ContentId("a"))).doThrow(RuntimeException()) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt new file mode 100644 index 0000000..5c33c9c --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt @@ -0,0 +1,153 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.shared.UnexpectedException +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.fnives.test.showcase.network.content.ContentRemoteSource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +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") +internal class ContentRepositoryTest { + + private lateinit var sut: ContentRepository + private lateinit var mockContentRemoteSource: ContentRemoteSource + private lateinit var testDispatcher: TestCoroutineDispatcher + + @BeforeEach + fun setUp() { + testDispatcher = TestCoroutineDispatcher() + mockContentRemoteSource = mock() + sut = ContentRepository(mockContentRemoteSource) + } + + @Test + fun GIVEN_no_interaction_THEN_remote_source_is_not_called() { + verifyNoMoreInteractions(mockContentRemoteSource) + } + + @Test + fun GIVEN_no_response_from_remote_source_WHEN_content_observed_THEN_loading_is_returned() = + runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_content_response_WHEN_content_observed_THEN_loading_AND_data_is_returned() = + runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_content_error_WHEN_content_observed_THEN_loading_AND_data_is_returned() = + runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_content_response_THEN_error_WHEN_fetched_THEN_returned_states_are_loading_data_loading_error() = + runBlockingTest(testDispatcher) { + 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(testDispatcher) { sut.contents.take(4).toList() } + testDispatcher.advanceUntilIdle() + sut.fetch() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + + @Test + fun GIVEN_content_response_THEN_error_WHEN_fetched_THEN_only_4_items_are_emitted() { + Assertions.assertThrows(IllegalStateException::class.java) { + runBlockingTest(testDispatcher) { + 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(testDispatcher) { sut.contents.take(5).toList() } + testDispatcher.advanceUntilIdle() + sut.fetch() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + } + } + + @Test + fun GIVEN_saved_cache_WHEN_collected_THEN_cache_is_returned() = runBlockingTest { + 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) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt new file mode 100644 index 0000000..8256cfd --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt @@ -0,0 +1,49 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +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.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class FetchContentUseCaseTest { + + private lateinit var sut: FetchContentUseCase + private lateinit var mockContentRepository: ContentRepository + + @BeforeEach + fun setUp() { + mockContentRepository = mock() + sut = FetchContentUseCase(mockContentRepository) + } + + @Test + fun WHEN_nothing_happens_THEN_the_storage_is_not_touched() { + verifyZeroInteractions(mockContentRepository) + } + + @Test + fun WHEN_called_THEN_repository_is_called() = runBlockingTest { + sut.invoke() + + verify(mockContentRepository, times(1)).fetch() + verifyNoMoreInteractions(mockContentRepository) + } + + @Test + fun GIVEN_throwing_local_storage_WHEN_thrown_THEN_its_thrown() = runBlockingTest { + whenever(mockContentRepository.fetch()).doThrow(RuntimeException()) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke() } + } + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt new file mode 100644 index 0000000..1fd4406 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt @@ -0,0 +1,214 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.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.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +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> + private lateinit var testDispatcher: TestCoroutineDispatcher + + @BeforeEach + fun setUp() { + testDispatcher = TestCoroutineDispatcher() + 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) + } + + @Test + fun GIVEN_loading_AND_empty_favourite_WHEN_observed_THEN_loading_is_shown() = runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_loading_AND_listOfFavourite_WHEN_observed_THEN_loading_is_shown() = runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_error_AND_empty_favourite_WHEN_observed_THEN_error_is_shown() = runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_error_AND_listOfFavourite_WHEN_observed_THEN_error_is_shown() = runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_listOfContent_AND_empty_favourite_WHEN_observed_THEN_favourites_are_returned() = runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_listOfContent_AND_other_favourite_id_WHEN_observed_THEN_favourites_are_returned() = + runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_listOfContent_AND_same_favourite_id_WHEN_observed_THEN_favourites_are_returned() = + runBlockingTest(testDispatcher) { + 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) + } + + @Test + fun GIVEN_loading_then_data_then_added_favourite_WHEN_observed_THEN_loading_then_correct_favourites_are_returned() = + runBlockingTest(testDispatcher) { + 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(testDispatcher) { + sut.get().take(3).toList() + } + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + testDispatcher.advanceUntilIdle() + + favouriteContentIdFlow.value = listOf(ContentId("a")) + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + + @Test + fun GIVEN_loading_then_data_then_removed_favourite_WHEN_observed_THEN_loading_then_correct_favourites_are_returned() = + runBlockingTest(testDispatcher) { + 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(testDispatcher) { + sut.get().take(3).toList() + } + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + testDispatcher.advanceUntilIdle() + + favouriteContentIdFlow.value = emptyList() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + + @Test + fun GIVEN_loading_then_data_then_loading_WHEN_observed_THEN_loading_then_correct_favourites_then_loadingare_returned() = + runBlockingTest(testDispatcher) { + 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(testDispatcher) { + sut.get().take(3).toList() + } + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Loading() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt new file mode 100644 index 0000000..c3d18da --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.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.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.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class RemoveContentFromFavouritesUseCaseTest { + + private lateinit var sut: RemoveContentFromFavouritesUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = RemoveContentFromFavouritesUseCase(mockFavouriteContentLocalStorage) + } + + @Test + fun WHEN_nothing_happens_THEN_the_storage_is_not_touched() { + verifyZeroInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_contentId_WHEN_called_THEN_storage_is_called() = runBlockingTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).deleteAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_throwing_local_storage_WHEN_thrown_THEN_its_thrown() = runBlockingTest { + whenever(mockFavouriteContentLocalStorage.deleteAsFavourite(ContentId("a"))).doThrow(RuntimeException()) + + Assertions.assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCaseTest.kt new file mode 100644 index 0000000..3b9d0a0 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCaseTest.kt @@ -0,0 +1,61 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.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.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyZeroInteractions +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) + } + + @Test + fun WHEN_nothing_is_called_THEN_storage_is_not_called() { + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_session_data_saved_WHEN_is_user_logged_in_checked_THEN_true_is_returned() { + whenever(mockUserDataLocalStorage.session).doReturn(Session("a", "b")) + + val actual = sut.invoke() + + Assertions.assertEquals(true, actual) + } + + @Test + fun GIVEN_no_session_data_saved_WHEN_is_user_logged_in_checked_THEN_false_is_returned() { + whenever(mockUserDataLocalStorage.session).doReturn(null) + + val actual = sut.invoke() + + Assertions.assertEquals(false, actual) + } + + @Test + fun GIVEN_no_session_THEN_session_THEN_no_session_WHEN_is_user_logged_in_checked_over_again_THEN_every_return_is_correct() { + 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/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt new file mode 100644 index 0000000..399dab7 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt @@ -0,0 +1,95 @@ +package org.fnives.test.showcase.core.login + +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.shared.UnexpectedException +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +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.fnives.test.showcase.network.auth.LoginRemoteSource +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +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.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +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) + } + + @Test + fun GIVEN_empty_username_WHEN_trying_to_login_THEN_invalid_username_is_returned() = runBlockingTest { + val expected = Answer.Success(LoginStatus.INVALID_USERNAME) + + val actual = sut.invoke(LoginCredentials("", "a")) + + Assertions.assertEquals(expected, actual) + verifyZeroInteractions(mockLoginRemoteSource) + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_empty_password_WHEN_trying_to_login_THEN_invalid_password_is_returned() = runBlockingTest { + val expected = Answer.Success(LoginStatus.INVALID_PASSWORD) + + val actual = sut.invoke(LoginCredentials("a", "")) + + Assertions.assertEquals(expected, actual) + verifyZeroInteractions(mockLoginRemoteSource) + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_login_invalid_credentials_response_WHEN_trying_to_login_THEN_invalid_credentials_is_returned() = runBlockingTest { + 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) + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_valid_login_response_WHEN_trying_to_login_THEN_Success_is_returned() = runBlockingTest { + 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") + } + + @Test + fun GIVEN_throwing_remote_source_WHEN_trying_to_login_THEN_error_is_returned() = runBlockingTest { + 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) + verifyZeroInteractions(mockUserDataLocalStorage) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/LogoutUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/LogoutUseCaseTest.kt new file mode 100644 index 0000000..ffb1900 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/login/LogoutUseCaseTest.kt @@ -0,0 +1,65 @@ +package org.fnives.test.showcase.core.login + +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.content.ContentRepository +import org.fnives.test.showcase.core.di.createCoreModule +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.network.BaseUrl +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.core.context.startKoin +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.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions + +@Suppress("TestFunctionName") +internal class LogoutUseCaseTest : KoinTest { + + private lateinit var sut: LogoutUseCase + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = LogoutUseCase(mockUserDataLocalStorage) + startKoin { + modules( + createCoreModule( + baseUrl = BaseUrl("https://a.b.com"), + enableNetworkLogging = true, + favouriteContentLocalStorageProvider = { mock() }, + sessionExpirationListenerProvider = { mock() }, + userDataLocalStorageProvider = { mock() } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun WHEN_no_call_THEN_storage_is_not_interacted() { + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun WHEN_logout_invoked_THEN_storage_is_cleared() = runBlockingTest { + val repositoryBefore = getKoin().get() + + sut.invoke() + + val repositoryAfter = getKoin().get() + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + Assertions.assertNotSame(repositoryBefore, repositoryAfter) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/session/SessionExpirationAdapterTest.kt b/core/src/test/java/org/fnives/test/showcase/core/session/SessionExpirationAdapterTest.kt new file mode 100644 index 0000000..8e1df4f --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/session/SessionExpirationAdapterTest.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.core.session + +import org.junit.jupiter.api.BeforeEach +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.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions + +@Suppress("TestFunctionName") +internal class SessionExpirationAdapterTest { + + private lateinit var sut: SessionExpirationAdapter + private lateinit var mockSessionExpirationListener: SessionExpirationListener + + @BeforeEach + fun setUp() { + mockSessionExpirationListener = mock() + sut = SessionExpirationAdapter(mockSessionExpirationListener) + } + + @Test + fun WHEN_onSessionExpired_is_called_THEN_its_delegated() { + sut.onSessionExpired() + + verify(mockSessionExpirationListener, times(1)).onSessionExpired() + verifyNoMoreInteractions(mockSessionExpirationListener) + } + + @Test + fun WHEN_nothing_is_changed_THEN_delegate_is_not_touched() { + verifyZeroInteractions(mockSessionExpirationListener) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt b/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt new file mode 100644 index 0000000..7706bb4 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt @@ -0,0 +1,79 @@ +package org.fnives.test.showcase.core.shared + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +internal class AnswerUtilsKtTest { + + @Test + fun GIVEN_network_exception_thrown_WHEN_wrapped_into_answer_THEN_answer_error_is_returned() = runBlocking { + val exception = NetworkException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_parsing_exception_thrown_WHEN_wrapped_into_answer_THEN_answer_error_is_returned() = runBlocking { + val exception = ParsingException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_parsing_throwable_thrown_WHEN_wrapped_into_answer_THEN_answer_error_is_returned() = runBlocking { + val exception = Throwable() + val expected = Answer.Error(UnexpectedException(exception)) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_string_WHEN_wrapped_into_answer_THEN_string_answer_is_returned() = runBlocking { + val expected = Answer.Success("banan") + + val actual = wrapIntoAnswer { "banan" } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_cancellation_exception_WHEN_wrapped_into_answer_THEN_cancellation_exception_is_thrown() { + Assertions.assertThrows(CancellationException::class.java) { + runBlocking { wrapIntoAnswer { throw CancellationException() } } + } + } + + @Test + fun GIVEN_success_answer_WHEN_converted_into_resource_THEN_Resource_success_is_returned() { + val expected = Resource.Success("alma") + + val actual = Answer.Success("alma").mapIntoResource() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_error_answer_WHEN_converted_into_resource_THEN_Resource_error_is_returned() { + val exception = Throwable() + val expected = Resource.Error(exception) + + val actual = Answer.Error(exception).mapIntoResource() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapterTest.kt b/core/src/test/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapterTest.kt new file mode 100644 index 0000000..6202975 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapterTest.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.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.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) + } + + @Test + fun GIVEN_null_as_session_WHEN_saved_THEN_its_delegated() { + sut.session = null + + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_session_WHEN_saved_THEN_its_delegated() { + val expected = Session("a", "b") + + sut.session = Session("a", "b") + + verify(mockUserDataLocalStorage, times(1)).session = expected + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @Test + fun WHEN_session_requested_THEN_its_returned_from_delegated() { + 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/core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/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/detekt/detekt.yml b/detekt/detekt.yml new file mode 100644 index 0000000..3e05232 --- /dev/null +++ b/detekt/detekt.yml @@ -0,0 +1,579 @@ +build: + maxIssues: 15 + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + # when writing own rules with new properties, exclude the property path e.g.: "my_rule_set,.*>.*>[my_property]" + excludes: "" + +processors: + active: true + exclude: + # - 'DetektProgressListener' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ClassCountProcessor' + # - 'PackageCountProcessor' + # - 'KtFileCountProcessor' + +console-reports: + active: true + exclude: + # - 'ProjectStatisticsReport' + # - 'ComplexityReport' + # - 'NotificationReport' + # - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'BuildFailureReport' + +comments: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$) + UndocumentedPublicClass: + active: false + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + UndocumentedPublicProperty: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + ComplexMethod: + active: true + threshold: 25 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: run,let,apply,with,also,use,forEach,isNotNull,ifNull + LabeledExpression: + active: false + ignoredLabels: "" + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 200 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 6 + ignoreDefaultParameters: false + MethodOverloading: + active: false + threshold: 6 + NestedBlockDepth: + active: true + threshold: 100 + StringLiteralDuplication: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + thresholdInFiles: 25 + thresholdInClasses: 25 + thresholdInInterfaces: 25 + thresholdInObjects: 25 + thresholdInEnums: 25 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + methodNames: 'toString,hashCode,equals,finalize' + InstanceOfCheckForException: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + ignoreLabeled: false + SwallowedException: + active: false + ignoredExceptionTypes: 'InterruptedException,NumberFormatException,ParseException,MalformedURLException' + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + exceptions: 'IllegalArgumentException,IllegalStateException,IOException' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + TooGenericExceptionThrown: + active: true + exceptionNames: + - Error + - Exception + - Throwable + - RuntimeException + +formatting: + active: true + android: false + autoCorrect: true + AnnotationOnSeparateLine: + active: false + autoCorrect: true + ChainWrapping: + active: true + autoCorrect: true + CommentSpacing: + active: true + autoCorrect: true + Filename: + active: true + FinalNewline: + active: true + autoCorrect: true + ImportOrdering: + active: true + autoCorrect: true + Indentation: + active: true + autoCorrect: true + indentSize: 4 + continuationIndentSize: 4 + MaximumLineLength: + active: true + maxLineLength: 250 + ModifierOrdering: + active: true + autoCorrect: true + MultiLineIfElse: + active: true + autoCorrect: true + NoBlankLineBeforeRbrace: + active: true + autoCorrect: true + NoConsecutiveBlankLines: + active: true + autoCorrect: true + NoEmptyClassBody: + active: true + autoCorrect: true + NoLineBreakAfterElse: + active: true + autoCorrect: true + NoLineBreakBeforeAssignment: + active: true + autoCorrect: true + NoMultipleSpaces: + active: true + autoCorrect: true + NoSemicolons: + active: true + autoCorrect: true + NoTrailingSpaces: + active: true + autoCorrect: true + NoUnitReturn: + active: true + autoCorrect: true + NoUnusedImports: + active: true + autoCorrect: true + NoWildcardImports: + active: true + PackageName: + active: true + autoCorrect: true + ParameterListWrapping: + active: true + autoCorrect: true + indentSize: 4 + SpacingAroundColon: + active: true + autoCorrect: true + SpacingAroundComma: + active: true + autoCorrect: true + SpacingAroundCurly: + active: true + autoCorrect: true + SpacingAroundDot: + active: true + autoCorrect: true + SpacingAroundKeyword: + active: true + autoCorrect: true + SpacingAroundOperators: + active: true + autoCorrect: true + SpacingAroundParens: + active: true + autoCorrect: true + SpacingAroundRangeOperator: + active: true + autoCorrect: true + StringTemplate: + active: true + autoCorrect: true + +naming: + active: true + ClassNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + classPattern: '[A-Z$][a-zA-Z0-9$]*' + ConstructorParameterNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + forbiddenName: '' + FunctionMaxLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$' + excludeClassPattern: '$^' + ignoreOverridden: true + FunctionParameterNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + InvalidPackageDeclaration: + active: false + rootPackage: '' + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + ObjectPropertyNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$' + TopLevelPropertyNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + maximumVariableNameLength: 64 + VariableMinLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + minimumVariableNameLength: 1 + VariableNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + SpreadOperator: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + Deprecation: + active: false + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + ImplicitDefaultLocale: + active: false + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + excludeAnnotatedProperties: "" + ignoreOnClassesPattern: "" + MissingWhenCase: + active: true + RedundantElseInWhen: + active: true + UnconditionalJumpStatementInLoop: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: true + +style: + active: true + CollapsibleIfStatements: + active: true + DataClassContainsFunctions: + active: true + conversionFunctionPrefix: 'to' + DataClassShouldBeImmutable: + active: false + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: true + includeLineWrapping: false + ForbiddenComment: + active: true + values: 'TODO:,FIXME:,STOPSHIP:' + allowedPatterns: "" + ForbiddenImport: + active: false + imports: '' + forbiddenPatterns: "" + ForbiddenVoid: + active: false + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + excludedFunctions: 'describeContents' + excludeAnnotatedFunction: "dagger.Provides" + LibraryCodeMustSpecifyReturnType: + active: true + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + ignoreNumbers: '-1,0,1,2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + MandatoryBracesIfStatements: + active: true + MaxLineLength: + active: true + maxLineLength: 160 #default: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + MayBeConst: + active: true + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 5 + excludedFunctions: "equals" + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 10 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableDecimalLength: 5 + UnnecessaryAbstractClass: + active: true + excludeAnnotatedClasses: "dagger.Module" + UnnecessaryApply: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: true + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: "(_|ignored|expected|serialVersionUID)" + UseArrayLiteralsInAnnotations: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + excludeAnnotatedClasses: "" + allowVars: false + UseIfInsteadOfWhen: + active: false + UseRequire: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: false + WildcardImport: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + excludeImports: 'java.util.*,kotlinx.android.synthetic.*' \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..98bed16 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f6b961fd5a86aa5fbfe90f707c3138408be7c718 GIT binary patch literal 54329 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

<5G2M1WZP5 zzqSuO91lJod(SBDDw<*sX(+F6Uq~YAeYV#2A;XQu_p=N5X+#cmu19Qk>QAnV=k!?wbk5I;tDWgFc}0NkvC*G=V+Yh1cyeJVq~9czZiDXe+S=VfL2g`LWo8om z$Y~FQc6MFjV-t1Y`^D9XMwY*U_re2R?&(O~68T&D4S{X`6JYU-pz=}ew-)V0AOUT1 zVOkHAB-8uBcRjLvz<9HS#a@X*Kc@|W)nyiSgi|u5$Md|P()%2(?olGg@ypoJwp6>m z*dnfjjWC>?_1p;%1brqZyDRR;8EntVA92EJ3ByOxj6a+bhPl z;a?m4rQAV1@QU^#M1HX)0+}A<7TCO`ZR_RzF}X9-M>cRLyN4C+lCk2)kT^3gN^`IT zNP~fAm(wyIoR+l^lQDA(e1Yv}&$I!n?&*p6?lZcQ+vGLLd~fM)qt}wsbf3r=tmVYe zl)ntf#E!P7wlakP9MXS7m0nsAmqxZ*)#j;M&0De`oNmFgi$ov#!`6^4)iQyxg5Iuj zjLAhzQ)r`^hf7`*1`Rh`X;LVBtDSz@0T?kkT1o!ijeyTGt5vc^Cd*tmNgiNo^EaWvaC8$e+nb_{W01j3%=1Y&92YacjCi>eNbwk%-gPQ@H-+4xskQ}f_c=jg^S-# zYFBDf)2?@5cy@^@FHK5$YdAK9cI;!?Jgd}25lOW%xbCJ>By3=HiK@1EM+I46A)Lsd zeT|ZH;KlCml=@;5+hfYf>QNOr^XNH%J-lvev)$Omy8MZ`!{`j>(J5cG&ZXXgv)TaF zg;cz99i$4CX_@3MIb?GL0s*8J=3`#P(jXF(_(6DXZjc@(@h&=M&JG)9&Te1?(^XMW zjjC_70|b=9hB6pKQi`S^Ls7JyJw^@P>Ko^&q8F&?>6i;#CbxUiLz1ZH4lNyd@QACd zu>{!sqjB!2Dg}pbAXD>d!3jW}=5aN0b;rw*W>*PAxm7D)aw(c*RX2@bTGEI|RRp}vw7;NR2wa;rXN{L{Q#=Fa z$x@ms6pqb>!8AuV(prv>|aU8oWV={C&$c zMa=p=CDNOC2tISZcd8~18GN5oTbKY+Vrq;3_obJlfSKRMk;Hdp1`y`&LNSOqeauR_ z^j*Ojl3Ohzb5-a49A8s|UnM*NM8tg}BJXdci5%h&;$afbmRpN0&~9rCnBA`#lG!p zc{(9Y?A0Y9yo?wSYn>iigf~KP$0*@bGZ>*YM4&D;@{<%Gg5^uUJGRrV4 z(aZOGB&{_0f*O=Oi0k{@8vN^BU>s3jJRS&CJOl3o|BE{FAA&a#2YYiX3pZz@|Go-F z|Fly;7eX2OTs>R}<`4RwpHFs9nwh)B28*o5qK1Ge=_^w0m`uJOv!=&!tzt#Save(C zgKU=Bsgql|`ui(e1KVxR`?>Dx>(rD1$iWp&m`v)3A!j5(6vBm*z|aKm*T*)mo(W;R zNGo2`KM!^SS7+*9YxTm6YMm_oSrLceqN*nDOAtagULuZl5Q<7mOnB@Hq&P|#9y{5B z!2x+2s<%Cv2Aa0+u{bjZXS);#IFPk(Ph-K7K?3i|4ro> zRbqJoiOEYo(Im^((r}U4b8nvo_>4<`)ut`24?ILnglT;Pd&U}$lV3U$F9#PD(O=yV zgNNA=GW|(E=&m_1;uaNmipQe?pon4{T=zK!N!2_CJL0E*R^XXIKf*wi!>@l}3_P9Z zF~JyMbW!+n-+>!u=A1ESxzkJy$DRuG+$oioG7(@Et|xVbJ#BCt;J43Nvj@MKvTxzy zMmjNuc#LXBxFAwIGZJk~^!q$*`FME}yKE8d1f5Mp}KHNq(@=Z8YxV}0@;YS~|SpGg$_jG7>_8WWYcVx#4SxpzlV9N4aO>K{c z$P?a_fyDzGX$Of3@ykvedGd<@-R;M^Shlj*SswJLD+j@hi_&_>6WZ}#AYLR0iWMK|A zH_NBeu(tMyG=6VO-=Pb>-Q#$F*or}KmEGg*-n?vWQREURdB#+6AvOj*I%!R-4E_2$ zU5n9m>RWs|Wr;h2DaO&mFBdDb-Z{APGQx$(L`if?C|njd*fC=rTS%{o69U|meRvu?N;Z|Y zbT|ojL>j;q*?xXmnHH#3R4O-59NV1j=uapkK7}6@Wo*^Nd#(;$iuGsb;H315xh3pl zHaJ>h-_$hdNl{+|Zb%DZH%ES;*P*v0#}g|vrKm9;j-9e1M4qX@zkl&5OiwnCz=tb6 zz<6HXD+rGIVpGtkb{Q^LIgExOm zz?I|oO9)!BOLW#krLmWvX5(k!h{i>ots*EhpvAE;06K|u_c~y{#b|UxQ*O@Ks=bca z^_F0a@61j3I(Ziv{xLb8AXQj3;R{f_l6a#H5ukg5rxwF9A$?Qp-Mo54`N-SKc}fWp z0T)-L@V$$&my;l#Ha{O@!fK4-FSA)L&3<${Hcwa7ue`=f&YsXY(NgeDU#sRlT3+9J z6;(^(sjSK@3?oMo$%L-nqy*E;3pb0nZLx6 z;h5)T$y8GXK1DS-F@bGun8|J(v-9o=42&nLJy#}M5D0T^5VWBNn$RpC zZzG6Bt66VY4_?W=PX$DMpKAI!d`INr) zkMB{XPQ<52rvWVQqgI0OL_NWxoe`xxw&X8yVftdODPj5|t}S6*VMqN$-h9)1MBe0N zYq?g0+e8fJCoAksr0af1)FYtz?Me!Cxn`gUx&|T;)695GG6HF7!Kg1zzRf_{VWv^bo81v4$?F6u2g|wxHc6eJQAg&V z#%0DnWm2Rmu71rPJ8#xFUNFC*V{+N_qqFH@gYRLZ6C?GAcVRi>^n3zQxORPG)$-B~ z%_oB?-%Zf7d*Fe;cf%tQwcGv2S?rD$Z&>QC2X^vwYjnr5pa5u#38cHCt4G3|efuci z@3z=#A13`+ztmp;%zjXwPY_aq-;isu*hecWWX_=Z8paSqq7;XYnUjK*T>c4~PR4W7 z#C*%_H&tfGx`Y$w7`dXvVhmovDnT>btmy~SLf>>~84jkoQ%cv=MMb+a{JV&t0+1`I z32g_Y@yDhKe|K^PevP~MiiVl{Ou7^Mt9{lOnXEQ`xY^6L8D$705GON{!1?1&YJEl#fTf5Z)da=yiEQ zGgtC-soFGOEBEB~ZF_{7b(76En>d}mI~XIwNw{e>=Fv)sgcw@qOsykWr?+qAOZSVrQfg}TNI ztKNG)1SRrAt6#Q?(me%)>&A_^DM`pL>J{2xu>xa$3d@90xR61TQDl@fu%_85DuUUA za9tn64?At;{`BAW6oykwntxHeDpXsV#{tmt5RqdN7LtcF4vR~_kZNT|wqyR#z^Xcd zFdymVRZvyLfTpBT>w9<)Ozv@;Yk@dOSVWbbtm^y@@C>?flP^EgQPAwsy75bveo=}T zFxl(f)s)j(0#N_>Or(xEuV(n$M+`#;Pc$1@OjXEJZumkaekVqgP_i}p`oTx;terTx zZpT+0dpUya2hqlf`SpXN{}>PfhajNk_J0`H|2<5E;U5Vh4F8er z;RxLSFgpGhkU>W?IwdW~NZTyOBrQ84H7_?gviIf71l`EETodG9a1!8e{jW?DpwjL? zGEM&eCzwoZt^P*8KHZ$B<%{I}>46IT%jJ3AnnB5P%D2E2Z_ z1M!vr#8r}1|KTqWA4%67ZdbMW2YJ81b(KF&SQ2L1Qn(y-=J${p?xLMx3W7*MK;LFQ z6Z`aU;;mTL4XrrE;HY*Rkh6N%?qviUGNAKiCB~!P}Z->IpO6E(gGd7I#eDuT7j|?nZ zK}I(EJ>$Kb&@338M~O+em9(L!+=0zBR;JAQesx|3?Ok90)D1aS9P?yTh6Poh8Cr4X zk3zc=f2rE7jj+aP7nUsr@~?^EGP>Q>h#NHS?F{Cn`g-gD<8F&dqOh-0sa%pfL`b+1 zUsF*4a~)KGb4te&K0}bE>z3yb8% zibb5Q%Sfiv7feb1r0tfmiMv z@^4XYwg@KZI=;`wC)`1jUA9Kv{HKe2t$WmRcR4y8)VAFjRi zaz&O7Y2tDmc5+SX(bj6yGHYk$dBkWc96u3u&F)2yEE~*i0F%t9Kg^L6MJSb&?wrXi zGSc;_rln$!^ybwYBeacEFRsVGq-&4uC{F)*Y;<0y7~USXswMo>j4?~5%Zm!m@i@-> zXzi82sa-vpU{6MFRktJy+E0j#w`f`>Lbog{zP|9~hg(r{RCa!uGe>Yl536cn$;ouH za#@8XMvS-kddc1`!1LVq;h57~zV`7IYR}pp3u!JtE6Q67 zq3H9ZUcWPm2V4IukS}MCHSdF0qg2@~ufNx9+VMjQP&exiG_u9TZAeAEj*jw($G)zL zq9%#v{wVyOAC4A~AF=dPX|M}MZV)s(qI9@aIK?Pe+~ch|>QYb+78lDF*Nxz2-vpRbtQ*F4$0fDbvNM#CCatgQ@z1+EZWrt z2dZfywXkiW=no5jus-92>gXn5rFQ-COvKyegmL=4+NPzw6o@a?wGE-1Bt;pCHe;34K%Z z-FnOb%!nH;)gX+!a3nCk?5(f1HaWZBMmmC@lc({dUah+E;NOros{?ui1zPC-Q0);w zEbJmdE$oU$AVGQPdm{?xxI_0CKNG$LbY*i?YRQ$(&;NiA#h@DCxC(U@AJ$Yt}}^xt-EC_ z4!;QlLkjvSOhdx!bR~W|Ezmuf6A#@T`2tsjkr>TvW*lFCMY>Na_v8+{Y|=MCu1P8y z89vPiH5+CKcG-5lzk0oY>~aJC_0+4rS@c@ZVKLAp`G-sJB$$)^4*A!B zmcf}lIw|VxV9NSoJ8Ag3CwN&d7`|@>&B|l9G8tXT^BDHOUPrtC70NgwN4${$k~d_4 zJ@eo6%YQnOgq$th?0{h`KnqYa$Nz@vlHw<%!C5du6<*j1nwquk=uY}B8r7f|lY+v7 zm|JU$US08ugor8E$h3wH$c&i~;guC|3-tqJy#T;v(g( zBZtPMSyv%jzf->435yM(-UfyHq_D=6;ouL4!ZoD+xI5uCM5ay2m)RPmm$I}h>()hS zO!0gzMxc`BPkUZ)WXaXam%1;)gedA7SM8~8yIy@6TPg!hR0=T>4$Zxd)j&P-pXeSF z9W`lg6@~YDhd19B9ETv(%er^Xp8Yj@AuFVR_8t*KS;6VHkEDKI#!@l!l3v6`W1`1~ zP{C@keuV4Q`Rjc08lx?zmT$e$!3esc9&$XZf4nRL(Z*@keUbk!GZi(2Bmyq*saOD? z3Q$V<*P-X1p2}aQmuMw9nSMbOzuASsxten7DKd6A@ftZ=NhJ(0IM|Jr<91uAul4JR zADqY^AOVT3a(NIxg|U;fyc#ZnSzw2cr}#a5lZ38>nP{05D)7~ad7JPhw!LqOwATXtRhK!w0X4HgS1i<%AxbFmGJx9?sEURV+S{k~g zGYF$IWSlQonq6}e;B(X(sIH|;52+(LYW}v_gBcp|x%rEAVB`5LXg_d5{Q5tMDu0_2 z|LOm$@K2?lrLNF=mr%YP|U-t)~9bqd+wHb4KuPmNK<}PK6e@aosGZK57=Zt+kcszVOSbe;`E^dN! ze7`ha3WUUU7(nS0{?@!}{0+-VO4A{7+nL~UOPW9_P(6^GL0h${SLtqG!} zKl~Ng5#@Sy?65wk9z*3SA`Dpd4b4T^@C8Fhd8O)k_4%0RZL5?#b~jmgU+0|DB%0Z) zql-cPC>A9HPjdOTpPC` zQwvF}uB5kG$Xr4XnaH#ruSjM*xG?_hT7y3G+8Ox`flzU^QIgb_>2&-f+XB6MDr-na zSi#S+c!ToK84<&m6sCiGTd^8pNdXo+$3^l3FL_E`0 z>8it5YIDxtTp2Tm(?}FX^w{fbfgh7>^8mtvN>9fWgFN_*a1P`Gz*dyOZF{OV7BC#j zQV=FQM5m>47xXgapI$WbPM5V`V<7J9tD)oz@d~MDoM`R^Y6-Na(lO~uvZlpu?;zw6 zVO1faor3dg#JEb5Q*gz4<W8tgC3nE2BG2jeIQs1)<{In&7hJ39x=;ih;CJDy)>0S1at*7n?Wr0ahYCpFjZ|@u91Zl7( zv;CSBRC65-6f+*JPf4p1UZ)k=XivKTX6_bWT~7V#rq0Xjas6hMO!HJN8GdpBKg_$B zwDHJF6;z?h<;GXFZan8W{XFNPpOj!(&I1`&kWO86p?Xz`a$`7qV7Xqev|7nn_lQuX ziGpU1MMYt&5dE2A62iX3;*0WzNB9*nSTzI%62A+N?f?;S>N@8M=|ef3gtQTIA*=yq zQAAjOqa!CkHOQo4?TsqrrsJLclXcP?dlAVv?v`}YUjo1Htt;6djP@NPFH+&p1I+f_ z)Y279{7OWomY8baT(4TAOlz1OyD{4P?(DGv3XyJTA2IXe=kqD)^h(@*E3{I~w;ws8 z)ZWv7E)pbEM zd3MOXRH3mQhks9 zv6{s;k0y5vrcjXaVfw8^>YyPo=oIqd5IGI{)+TZq5Z5O&hXAw%ZlL}^6FugH;-%vP zAaKFtt3i^ag226=f0YjzdPn6|4(C2sC5wHFX{7QF!tG1E-JFA`>eZ`}$ymcRJK?0c zN363o{&ir)QySOFY0vcu6)kX#;l??|7o{HBDVJN+17rt|w3;(C_1b>d;g9Gp=8YVl zYTtA52@!7AUEkTm@P&h#eg+F*lR zQ7iotZTcMR1frJ0*V@Hw__~CL>_~2H2cCtuzYIUD24=Cv!1j6s{QS!v=PzwQ(a0HS zBKx04KA}-Ue+%9d`?PG*hIij@54RDSQpA7|>qYVIrK_G6%6;#ZkR}NjUgmGju)2F`>|WJoljo)DJgZr4eo1k1i1+o z1D{>^RlpIY8OUaOEf5EBu%a&~c5aWnqM zxBpJq98f=%M^{4mm~5`CWl%)nFR64U{(chmST&2jp+-r z3675V<;Qi-kJud%oWnCLdaU-)xTnMM%rx%Jw6v@=J|Ir=4n-1Z23r-EVf91CGMGNz zb~wyv4V{H-hkr3j3WbGnComiqmS0vn?n?5v2`Vi>{Ip3OZUEPN7N8XeUtF)Ry6>y> zvn0BTLCiqGroFu|m2zG-;Xb6;W`UyLw)@v}H&(M}XCEVXZQoWF=Ykr5lX3XWwyNyF z#jHv)A*L~2BZ4lX?AlN3X#axMwOC)PoVy^6lCGse9bkGjb=qz%kDa6}MOmSwK`cVO zt(e*MW-x}XtU?GY5}9{MKhRhYOlLhJE5=ca+-RmO04^ z66z{40J=s=ey9OCdc(RCzy zd7Zr1%!y3}MG(D=wM_ebhXnJ@MLi7cImDkhm0y{d-Vm81j`0mbi4lF=eirlr)oW~a zCd?26&j^m4AeXEsIUXiTal)+SPM4)HX%%YWF1?(FV47BaA`h9m67S9x>hWMVHx~Hg z1meUYoLL(p@b3?x|9DgWeI|AJ`Ia84*P{Mb%H$ZRROouR4wZhOPX15=KiBMHl!^JnCt$Az`KiH^_d>cev&f zaG2>cWf$=A@&GP~DubsgYb|L~o)cn5h%2`i^!2)bzOTw2UR!>q5^r&2Vy}JaWFUQE04v>2;Z@ZPwXr?y&G(B^@&y zsd6kC=hHdKV>!NDLIj+3rgZJ|dF`%N$DNd;B)9BbiT9Ju^Wt%%u}SvfM^=|q-nxDG zuWCQG9e#~Q5cyf8@y76#kkR^}{c<_KnZ0QsZcAT|YLRo~&tU|N@BjxOuy`#>`X~Q< z?R?-Gsk$$!oo(BveQLlUrcL#eirhgBLh`qHEMg`+sR1`A=1QX7)ZLMRT+GBy?&mM8 zQG^z-!Oa&J-k7I(3_2#Q6Bg=NX<|@X&+YMIOzfEO2$6Mnh}YV!m!e^__{W@-CTprr zbdh3f=BeCD$gHwCrmwgM3LAv3!Mh$wM)~KWzp^w)Cu6roO7uUG5z*}i0_0j47}pK; ztN530`ScGatLOL06~zO)Qmuv`h!gq5l#wx(EliKe&rz-5qH(hb1*fB#B+q`9=jLp@ zOa2)>JTl7ovxMbrif`Xe9;+fqB1K#l=Dv!iT;xF zdkCvS>C5q|O;}ns3AgoE({Ua-zNT-9_5|P0iANmC6O76Sq_(AN?UeEQJ>#b54fi3k zFmh+P%b1x3^)0M;QxXLP!BZ^h|AhOde*{9A=f3|Xq*JAs^Y{eViF|=EBfS6L%k4ip zk+7M$gEKI3?bQg?H3zaE@;cyv9kv;cqK$VxQbFEsy^iM{XXW0@2|DOu$!-k zSFl}Y=jt-VaT>Cx*KQnHTyXt}f9XswFB9ibYh+k2J!ofO+nD?1iw@mwtrqI4_i?nE zhLkPp41ED62me}J<`3RN80#vjW;wt`pP?%oQ!oqy7`miL>d-35a=qotK$p{IzeSk# ze_$CFYp_zIkrPFVaW^s#U4xT1lI^A0IBe~Y<4uS%zSV=wcuLr%gQT=&5$&K*bwqx| zWzCMiz>7t^Et@9CRUm9E+@hy~sBpm9fri$sE1zgLU((1?Yg{N1Sars=DiW&~Zw=3I zi7y)&oTC?UWD2w97xQ&5vx zRXEBGeJ(I?Y}eR0_O{$~)bMJRTsNUPIfR!xU9PE7A>AMNr_wbrFK>&vVw=Y;RH zO$mlpmMsQ}-FQ2cSj7s7GpC+~^Q~dC?y>M}%!-3kq(F3hGWo9B-Gn02AwUgJ>Z-pKOaj zysJBQx{1>Va=*e@sLb2z&RmQ7ira;aBijM-xQ&cpR>X3wP^foXM~u1>sv9xOjzZpX z0K;EGouSYD~oQ&lAafj3~EaXfFShC+>VsRlEMa9cg9i zFxhCKO}K0ax6g4@DEA?dg{mo>s+~RPI^ybb^u--^nTF>**0l5R9pocwB?_K)BG_)S zyLb&k%XZhBVr7U$wlhMqwL)_r&&n%*N$}~qijbkfM|dIWP{MyLx}X&}ES?}7i;9bW zmTVK@zR)7kE2+L42Q`n4m0VVg5l5(W`SC9HsfrLZ=v%lpef=Gj)W59VTLe+Z$8T8i z4V%5+T0t8LnM&H>Rsm5C%qpWBFqgTwL{=_4mE{S3EnBXknM&u8n}A^IIM4$s3m(Rd z>zq=CP-!9p9es2C*)_hoL@tDYABn+o#*l;6@7;knWIyDrt5EuakO99S$}n((Fj4y} zD!VvuRzghcE{!s;jC*<_H$y6!6QpePo2A3ZbX*ZzRnQq*b%KK^NF^z96CHaWmzU@f z#j;y?X=UP&+YS3kZx7;{ zDA{9(wfz7GF`1A6iB6fnXu0?&d|^p|6)%3$aG0Uor~8o? z*e}u#qz7Ri?8Uxp4m_u{a@%bztvz-BzewR6bh*1Xp+G=tQGpcy|4V_&*aOqu|32CM zz3r*E8o8SNea2hYJpLQ-_}R&M9^%@AMx&`1H8aDx4j%-gE+baf2+9zI*+Pmt+v{39 zDZ3Ix_vPYSc;Y;yn68kW4CG>PE5RoaV0n@#eVmk?p$u&Fy&KDTy!f^Hy6&^-H*)#u zdrSCTJPJw?(hLf56%2;_3n|ujUSJOU8VPOTlDULwt0jS@j^t1WS z!n7dZIoT+|O9hFUUMbID4Ec$!cc($DuQWkocVRcYSikFeM&RZ=?BW)mG4?fh#)KVG zcJ!<=-8{&MdE)+}?C8s{k@l49I|Zwswy^ZN3;E!FKyglY~Aq?4m74P-0)sMTGXqd5(S<-(DjjM z&7dL-Mr8jhUCAG$5^mI<|%`;JI5FVUnNj!VO2?Jiqa|c2;4^n!R z`5KK0hyB*F4w%cJ@Un6GC{mY&r%g`OX|1w2$B7wxu97%<@~9>NlXYd9RMF2UM>(z0 zouu4*+u+1*k;+nFPk%ly!nuMBgH4sL5Z`@Rok&?Ef=JrTmvBAS1h?C0)ty5+yEFRz zY$G=coQtNmT@1O5uk#_MQM1&bPPnspy5#>=_7%WcEL*n$;sSAZcXxMpcXxLe;_mLA z5F_paad+bGZV*oh@8h0(|D2P!q# zTHjmiphJ=AazSeKQPkGOR-D8``LjzToyx{lfK-1CDD6M7?pMZOdLKFtjZaZMPk4}k zW)97Fh(Z+_Fqv(Q_CMH-YYi?fR5fBnz7KOt0*t^cxmDoIokc=+`o# zrud|^h_?KW=Gv%byo~(Ln@({?3gnd?DUf-j2J}|$Mk>mOB+1{ZQ8HgY#SA8END(Zw z3T+W)a&;OO54~m}ffemh^oZ!Vv;!O&yhL0~hs(p^(Yv=(3c+PzPXlS5W79Er8B1o* z`c`NyS{Zj_mKChj+q=w)B}K za*zzPhs?c^`EQ;keH{-OXdXJet1EsQ)7;{3eF!-t^4_Srg4(Ot7M*E~91gwnfhqaM zNR7dFaWm7MlDYWS*m}CH${o?+YgHiPC|4?X?`vV+ws&Hf1ZO-w@OGG^o4|`b{bLZj z&9l=aA-Y(L11!EvRjc3Zpxk7lc@yH1e$a}8$_-r$)5++`_eUr1+dTb@ zU~2P1HM#W8qiNN3b*=f+FfG1!rFxnNlGx{15}BTIHgxO>Cq4 z;#9H9YjH%>Z2frJDJ8=xq>Z@H%GxXosS@Z>cY9ppF+)e~t_hWXYlrO6)0p7NBMa`+ z^L>-#GTh;k_XnE)Cgy|0Dw;(c0* zSzW14ZXozu)|I@5mRFF1eO%JM=f~R1dkNpZM+Jh(?&Zje3NgM{2ezg1N`AQg5%+3Y z64PZ0rPq6;_)Pj-hyIOgH_Gh`1$j1!jhml7ksHA1`CH3FDKiHLz+~=^u@kUM{ilI5 z^FPiJ7mSrzBs9{HXi2{sFhl5AyqwUnU{sPcUD{3+l-ZHAQ)C;c$=g1bdoxeG(5N01 zZy=t8i{*w9m?Y>V;uE&Uy~iY{pY4AV3_N;RL_jT_QtLFx^KjcUy~q9KcLE3$QJ{!)@$@En{UGG7&}lc*5Kuc^780;7Bj;)X?1CSy*^^ zPP^M)Pr5R>mvp3_hmCtS?5;W^e@5BjE>Cs<`lHDxj<|gtOK4De?Sf0YuK5GX9G93i zMYB{8X|hw|T6HqCf7Cv&r8A$S@AcgG1cF&iJ5=%+x;3yB`!lQ}2Hr(DE8=LuNb~Vs z=FO&2pdc16nD$1QL7j+!U^XWTI?2qQKt3H8=beVTdHHa9=MiJ&tM1RRQ-=+vy!~iz zj3O{pyRhCQ+b(>jC*H)J)%Wq}p>;?@W*Eut@P&?VU+Sdw^4kE8lvX|6czf{l*~L;J zFm*V~UC;3oQY(ytD|D*%*uVrBB}BbAfjK&%S;z;7$w68(8PV_whC~yvkZmX)xD^s6 z{$1Q}q;99W?*YkD2*;)tRCS{q2s@JzlO~<8x9}X<0?hCD5vpydvOw#Z$2;$@cZkYrp83J0PsS~!CFtY%BP=yxG?<@#{7%2sy zOc&^FJxsUYN36kSY)d7W=*1-{7ghPAQAXwT7z+NlESlkUH&8ODlpc8iC*iQ^MAe(B z?*xO4i{zFz^G=^G#9MsLKIN64rRJykiuIVX5~0#vAyDWc9-=6BDNT_aggS2G{B>dD ze-B%d3b6iCfc5{@yz$>=@1kdK^tX9qh0=ocv@9$ai``a_ofxT=>X7_Y0`X}a^M?d# z%EG)4@`^Ej_=%0_J-{ga!gFtji_byY&Vk@T1c|ucNAr(JNr@)nCWj?QnCyvXg&?FW;S-VOmNL6^km_dqiVjJuIASVGSFEos@EVF7St$WE&Z%)`Q##+0 zjaZ=JI1G@0!?l|^+-ZrNd$WrHBi)DA0-Eke>dp=_XpV<%CO_Wf5kQx}5e<90dt>8k zAi00d0rQ821nA>B4JHN7U8Zz=0;9&U6LOTKOaC1FC8GgO&kc=_wHIOGycL@c*$`ce703t%>S}mvxEnD-V!;6c`2(p74V7D0No1Xxt`urE66$0(ThaAZ1YVG#QP$ zy~NN%kB*zhZ2Y!kjn826pw4bh)75*e!dse+2Db(;bN34Uq7bLpr47XTX{8UEeC?2i z*{$`3dP}32${8pF$!$2Vq^gY|#w+VA_|o(oWmQX8^iw#n_crb(K3{69*iU?<%C-%H zuKi)3M1BhJ@3VW>JA`M>L~5*_bxH@Euy@niFrI$82C1}fwR$p2E&ZYnu?jlS}u7W9AyfdXh2pM>78bIt3 z)JBh&XE@zA!kyCDfvZ1qN^np20c1u#%P6;6tU&dx0phT1l=(mw7`u!-0e=PxEjDds z9E}{E!7f9>jaCQhw)&2TtG-qiD)lD(4jQ!q{`x|8l&nmtHkdul# zy+CIF8lKbp9_w{;oR+jSLtTfE+B@tOd6h=QePP>rh4@~!8c;Hlg9m%%&?e`*Z?qz5-zLEWfi>`ord5uHF-s{^bexKAoMEV@9nU z^5nA{f{dW&g$)BAGfkq@r5D)jr%!Ven~Q58c!Kr;*Li#`4Bu_?BU0`Y`nVQGhNZk@ z!>Yr$+nB=`z#o2nR0)V3M7-eVLuY`z@6CT#OTUXKnxZn$fNLPv7w1y7eGE=Qv@Hey`n;`U=xEl|q@CCV^#l)s0ZfT+mUf z^(j5r4)L5i2jnHW4+!6Si3q_LdOLQi<^fu?6WdohIkn79=jf%Fs3JkeXwF(?_tcF? z?z#j6iXEd(wJy4|p6v?xNk-)iIf2oX5^^Y3q3ziw16p9C6B;{COXul%)`>nuUoM*q zzmr|NJ5n)+sF$!yH5zwp=iM1#ZR`O%L83tyog-qh1I z0%dcj{NUs?{myT~33H^(%0QOM>-$hGFeP;U$puxoJ>>o-%Lk*8X^rx1>j|LtH$*)>1C!Pv&gd16%`qw5LdOIUbkNhaBBTo}5iuE%K&ZV^ zAr_)kkeNKNYJRgjsR%vexa~&8qMrQYY}+RbZ)egRg9_$vkoyV|Nc&MH@8L)`&rpqd zXnVaI@~A;Z^c3+{x=xgdhnocA&OP6^rr@rTvCnhG6^tMox$ulw2U7NgUtW%|-5VeH z_qyd47}1?IbuKtqNbNx$HR`*+9o=8`%vM8&SIKbkX9&%TS++x z5|&6P<%=F$C?owUI`%uvUq^yW0>`>yz!|WjzsoB9dT;2Dx8iSuK%%_XPgy0dTD4kd zDXF@&O_vBVVKQq(9YTClUPM30Sk7B!v7nOyV`XC!BA;BIVwphh+c)?5VJ^(C;GoQ$ zvBxr7_p*k$T%I1ke}`U&)$uf}I_T~#3XTi53OX)PoXVgxEcLJgZG^i47U&>LY(l%_ z;9vVDEtuMCyu2fqZeez|RbbIE7@)UtJvgAcVwVZNLccswxm+*L&w`&t=ttT=sv6Aq z!HouSc-24Y9;0q$>jX<1DnnGmAsP))- z^F~o99gHZw`S&Aw7e4id6Lg7kMk-e)B~=tZ!kE7sGTOJ)8@q}np@j7&7Sy{2`D^FH zI7aX%06vKsfJ168QnCM2=l|i>{I{%@gcr>ExM0Dw{PX6ozEuqFYEt z087%MKC;wVsMV}kIiuu9Zz9~H!21d!;Cu#b;hMDIP7nw3xSX~#?5#SSjyyg+Y@xh| z%(~fv3`0j#5CA2D8!M2TrG=8{%>YFr(j)I0DYlcz(2~92?G*?DeuoadkcjmZszH5& zKI@Lis%;RPJ8mNsbrxH@?J8Y2LaVjUIhRUiO-oqjy<&{2X~*f|)YxnUc6OU&5iac= z*^0qwD~L%FKiPmlzi&~a*9sk2$u<7Al=_`Ox^o2*kEv?p`#G(p(&i|ot8}T;8KLk- zPVf_4A9R`5^e`Om2LV*cK59EshYXse&IoByj}4WZaBomoHAPKqxRKbPcD`lMBI)g- zeMRY{gFaUuecSD6q!+b5(?vAnf>c`Z(8@RJy%Ulf?W~xB1dFAjw?CjSn$ph>st5bc zUac1aD_m6{l|$#g_v6;=32(mwpveQDWhmjR7{|B=$oBhz`7_g7qNp)n20|^^op3 zSfTdWV#Q>cb{CMKlWk91^;mHap{mk)o?udk$^Q^^u@&jd zfZ;)saW6{e*yoL6#0}oVPb2!}r{pAUYtn4{P~ES9tTfC5hXZnM{HrC8^=Pof{G4%Bh#8 ze~?C9m*|fd8MK;{L^!+wMy>=f^8b&y?yr6KnTq28$pFMBW9Oy7!oV5z|VM$s-cZ{I|Xf@}-)1=$V&x7e;9v81eiTi4O5-vs?^5pCKy2l>q);!MA zS!}M48l$scB~+Umz}7NbwyTn=rqt@`YtuwiQSMvCMFk2$83k50Q>OK5&fe*xCddIm)3D0I6vBU<+!3=6?(OhkO|b4fE_-j zimOzyfBB_*7*p8AmZi~X2bgVhyPy>KyGLAnOpou~sx9)S9%r)5dE%ADs4v%fFybDa_w*0?+>PsEHTbhKK^G=pFz z@IxLTCROWiKy*)cV3y%0FwrDvf53Ob_XuA1#tHbyn%Ko!1D#sdhBo`;VC*e1YlhrC z?*y3rp86m#qI|qeo8)_xH*G4q@70aXN|SP+6MQ!fJQqo1kwO_v7zqvUfU=Gwx`CR@ zRFb*O8+54%_8tS(ADh}-hUJzE`s*8wLI>1c4b@$al)l}^%GuIXjzBK!EWFO8W`>F^ ze7y#qPS0NI7*aU)g$_ziF(1ft;2<}6Hfz10cR8P}67FD=+}MfhrpOkF3hFhQu;Q1y zu%=jJHTr;0;oC94Hi@LAF5quAQ(rJG(uo%BiRQ@8U;nhX)j0i?0SL2g-A*YeAqF>RVCBOTrn{0R27vu}_S zS>tX4!#&U4W;ikTE!eFH+PKw%p+B(MR2I%n#+m0{#?qRP_tR@zpgCb=4rcrL!F=;A zh%EIF8m6%JG+qb&mEfuFTLHSxUAZEvC-+kvZKyX~SA3Umt`k}}c!5dy?-sLIM{h@> z!2=C)@nx>`;c9DdwZ&zeUc(7t<21D7qBj!|1^Mp1eZ6)PuvHx+poKSDCSBMFF{bKy z;9*&EyKitD99N}%mK8431rvbT+^%|O|HV23{;RhmS{$5tf!bIPoH9RKps`-EtoW5h zo6H_!s)Dl}2gCeGF6>aZtah9iLuGd19^z0*OryPNt{70RvJSM<#Ox9?HxGg04}b^f zrVEPceD%)#0)v5$YDE?f`73bQ6TA6wV;b^x*u2Ofe|S}+q{s5gr&m~4qGd!wOu|cZ||#h_u=k*fB;R6&k?FoM+c&J;ISg70h!J7*xGus)ta4veTdW)S^@sU@ z4$OBS=a~@F*V0ECic;ht4@?Jw<9kpjBgHfr2FDPykCCz|v2)`JxTH55?b3IM={@DU z!^|9nVO-R#s{`VHypWyH0%cs;0GO3E;It6W@0gX6wZ%W|Dzz&O%m17pa19db(er}C zUId1a4#I+Ou8E1MU$g=zo%g7K(=0Pn$)Rk z<4T2u<0rD)*j+tcy2XvY+0 z0d2pqm4)4lDewsAGThQi{2Kc3&C=|OQF!vOd#WB_`4gG3@inh-4>BoL!&#ij8bw7? zqjFRDaQz!J-YGitV4}$*$hg`vv%N)@#UdzHFI2E<&_@0Uw@h_ZHf}7)G;_NUD3@18 zH5;EtugNT0*RXVK*by>WS>jaDDfe!A61Da=VpIK?mcp^W?!1S2oah^wowRnrYjl~`lgP-mv$?yb6{{S55CCu{R z$9;`dyf0Y>uM1=XSl_$01Lc1Iy68IosWN8Q9Op=~I(F<0+_kKfgC*JggjxNgK6 z-3gQm6;sm?J&;bYe&(dx4BEjvq}b`OT^RqF$J4enP1YkeBK#>l1@-K`ajbn05`0J?0daOtnzh@l3^=BkedW1EahZlRp;`j*CaT;-21&f2wU z+Nh-gc4I36Cw+;3UAc<%ySb`#+c@5y ze~en&bYV|kn?Cn|@fqmGxgfz}U!98$=drjAkMi`43I4R%&H0GKEgx-=7PF}y`+j>r zg&JF`jomnu2G{%QV~Gf_-1gx<3Ky=Md9Q3VnK=;;u0lyTBCuf^aUi?+1+`4lLE6ZK zT#(Bf`5rmr(tgTbIt?yA@y`(Ar=f>-aZ}T~>G32EM%XyFvhn&@PWCm#-<&ApLDCXT zD#(9m|V(OOo7PmE@`vD4$S5;+9IQm19dd zvMEU`)E1_F+0o0-z>YCWqg0u8ciIknU#{q02{~YX)gc_u;8;i233D66pf(IkTDxeN zL=4z2)?S$TV9=ORVr&AkZMl<4tTh(v;Ix1{`pPVqI3n2ci&4Dg+W|N8TBUfZ*WeLF zqCH_1Q0W&f9T$lx3CFJ$o@Lz$99 zW!G&@zFHxTaP!o#z^~xgF|(vrHz8R_r9eo;TX9}2ZyjslrtH=%6O)?1?cL&BT(Amp zTGFU1%%#xl&6sH-UIJk_PGk_McFn7=%yd6tAjm|lnmr8bE2le3I~L{0(ffo}TQjyo zHZZI{-}{E4ohYTlZaS$blB!h$Jq^Rf#(ch}@S+Ww&$b);8+>g84IJcLU%B-W?+IY& zslcZIR>+U4v3O9RFEW;8NpCM0w1ROG84=WpKxQ^R`{=0MZCubg3st z48AyJNEvyxn-jCPTlTwp4EKvyEwD3e%kpdY?^BH0!3n6Eb57_L%J1=a*3>|k68A}v zaW`*4YitylfD}ua8V)vb79)N_Ixw_mpp}yJGbNu+5YYOP9K-7nf*jA1#<^rb4#AcS zKg%zCI)7cotx}L&J8Bqo8O1b0q;B1J#B5N5Z$Zq=wX~nQFgUfAE{@u0+EnmK{1hg> zC{vMfFLD;L8b4L+B51&LCm|scVLPe6h02rws@kGv@R+#IqE8>Xn8i|vRq_Z`V;x6F zNeot$1Zsu`lLS92QlLWF54za6vOEKGYQMdX($0JN*cjG7HP&qZ#3+bEN$8O_PfeAb z0R5;=zXac2IZ?fxu59?Nka;1lKm|;0)6|#RxkD05P5qz;*AL@ig!+f=lW5^Jbag%2 z%9@iM0ph$WFlxS!`p31t92z~TB}P-*CS+1Oo_g;7`6k(Jyj8m8U|Q3Sh7o-Icp4kV zK}%qri5>?%IPfamXIZ8pXbm-#{ytiam<{a5A+3dVP^xz!Pvirsq7Btv?*d7eYgx7q zWFxrzb3-%^lDgMc=Vl7^={=VDEKabTG?VWqOngE`Kt7hs236QKidsoeeUQ_^FzsXjprCDd@pW25rNx#6x&L6ZEpoX9Ffzv@olnH3rGOSW( zG-D|cV0Q~qJ>-L}NIyT?T-+x+wU%;+_GY{>t(l9dI%Ximm+Kmwhee;FK$%{dnF;C% zFjM2&$W68Sz#d*wtfX?*WIOXwT;P6NUw}IHdk|)fw*YnGa0rHx#paG!m=Y6GkS4VX zX`T$4eW9k1W!=q8!(#8A9h67fw))k_G)Q9~Q1e3f`aV@kbcSv7!priDUN}gX(iXTy zr$|kU0Vn%*ylmyDCO&G0Z3g>%JeEPFAW!5*H2Ydl>39w3W+gEUjL&vrRs(xGP{(ze zy7EMWF14@Qh>X>st8_029||TP0>7SG9on_xxeR2Iam3G~Em$}aGsNt$iES9zFa<3W zxtOF*!G@=PhfHO!=9pVPXMUVi30WmkPoy$02w}&6A7mF)G6-`~EVq5CwD2`9Zu`kd)52``#V zNSb`9dG~8(dooi1*-aSMf!fun7Sc`-C$-E(3BoSC$2kKrVcI!&yC*+ff2+C-@!AT_ zsvlAIV+%bRDfd{R*TMF><1&_a%@yZ0G0lg2K;F>7b+7A6pv3-S7qWIgx+Z?dt8}|S z>Qbb6x(+^aoV7FQ!Ph8|RUA6vXWQH*1$GJC+wXLXizNIc9p2yLzw9 z0=MdQ!{NnOwIICJc8!+Jp!zG}**r#E!<}&Te&}|B4q;U57$+pQI^}{qj669zMMe_I z&z0uUCqG%YwtUc8HVN7?0GHpu=bL7&{C>hcd5d(iFV{I5c~jpX&!(a{yS*4MEoYXh z*X4|Y@RVfn;piRm-C%b@{0R;aXrjBtvx^HO;6(>i*RnoG0Rtcd25BT6edxTNOgUAOjn zJ2)l{ipj8IP$KID2}*#F=M%^n&=bA0tY98@+2I+7~A&T-tw%W#3GV>GTmkHaqftl)#+E zMU*P(Rjo>8%P@_@#UNq(_L{}j(&-@1iY0TRizhiATJrnvwSH0v>lYfCI2ex^><3$q znzZgpW0JlQx?JB#0^^s-Js1}}wKh6f>(e%NrMwS`Q(FhazkZb|uyB@d%_9)_xb$6T zS*#-Bn)9gmobhAtvBmL+9H-+0_0US?g6^TOvE8f3v=z3o%NcPjOaf{5EMRnn(_z8- z$|m0D$FTU zDy;21v-#0i)9%_bZ7eo6B9@Q@&XprR&oKl4m>zIj-fiRy4Dqy@VVVs?rscG| zmzaDQ%>AQTi<^vYCmv#KOTd@l7#2VIpsj?nm_WfRZzJako`^uU%Nt3e;cU*y*|$7W zLm%fX#i_*HoUXu!NI$ey>BA<5HQB=|nRAwK!$L#n-Qz;~`zACig0PhAq#^5QS<8L2 zS3A+8%vbVMa7LOtTEM?55apt(DcWh#L}R^P2AY*c8B}Cx=6OFAdMPj1f>k3#^#+Hk z6uW1WJW&RlBRh*1DLb7mJ+KO>!t^t8hX1#_Wk`gjDio9)9IGbyCAGI4DJ~orK+YRv znjxRMtshZQHc$#Y-<-JOV6g^Cr@odj&Xw5B(FmI)*qJ9NHmIz_r{t)TxyB`L-%q5l ztzHgD;S6cw?7Atg*6E1!c6*gPRCb%t7D%z<(xm+K{%EJNiI2N0l8ud0Ch@_av_RW? zIr!nO4dL5466WslE6MsfMss7<)-S!e)2@r2o=7_W)OO`~CwklRWzHTfpB)_HYwgz=BzLhgZ9S<{nLBOwOIgJU=94uj6r!m>Xyn9>&xP+=5!zG_*yEoRgM0`aYts z^)&8(>z5C-QQ*o_s(8E4*?AX#S^0)aqB)OTyX>4BMy8h(cHjA8ji1PRlox@jB*1n? zDIfyDjzeg91Ao(;Q;KE@zei$}>EnrF6I}q&Xd=~&$WdDsyH0H7fJX|E+O~%LS*7^Q zYzZ4`pBdY{b7u72gZm6^5~O-57HwzwAz{)NvVaowo`X02tL3PpgLjwA`^i9F^vSpN zAqH3mRjG8VeJNHZ(1{%!XqC+)Z%D}58Qel{_weSEHoygT9pN@i zi=G;!Vj6XQk2tuJC>lza%ywz|`f7TIz*EN2Gdt!s199Dr4Tfd_%~fu8gXo~|ogt5Q zlEy_CXEe^BgsYM^o@L?s33WM14}7^T(kqohOX_iN@U?u;$l|rAvn{rwy>!yfZw13U zB@X9)qt&4;(C6dP?yRsoTMI!j-f1KC!<%~i1}u7yLXYn)(#a;Z6~r>hp~kfP));mi zcG%kdaB9H)z9M=H!f>kM->fTjRVOELNwh1amgKQT=I8J66kI)u_?0@$$~5f`u%;zl zC?pkr^p2Fe=J~WK%4ItSzKA+QHqJ@~m|Cduv=Q&-P8I5rQ-#G@bYH}YJr zUS(~(w|vKyU(T(*py}jTUp%I%{2!W!K(i$uvotcPjVddW z8_5HKY!oBCwGZcs-q`4Yt`Zk~>K?mcxg51wkZlX5e#B08I75F7#dgn5yf&Hrp`*%$ zQ;_Qg>TYRzBe$x=T(@WI9SC!ReSas9vDm(yslQjBJZde5z8GDU``r|N(MHcxNopGr z_}u39W_zwWDL*XYYt>#Xo!9kL#97|EAGyGBcRXtLTd59x%m=3i zL^9joWYA)HfL15l9%H?q`$mY27!<9$7GH(kxb%MV>`}hR4a?+*LH6aR{dzrX@?6X4 z3e`9L;cjqYb`cJmophbm(OX0b)!AFG?5`c#zLagzMW~o)?-!@e80lvk!p#&CD8u5_r&wp4O0zQ>y!k5U$h_K;rWGk=U)zX!#@Q%|9g*A zWx)qS1?fq6X<$mQTB$#3g;;5tHOYuAh;YKSBz%il3Ui6fPRv#v62SsrCdMRTav)Sg zTq1WOu&@v$Ey;@^+_!)cf|w_X<@RC>!=~+A1-65O0bOFYiH-)abINwZvFB;hJjL_$ z(9iScmUdMp2O$WW!520Hd0Q^Yj?DK%YgJD^ez$Z^?@9@Ab-=KgW@n8nC&88)TDC+E zlJM)L3r+ZJfZW_T$;Imq*#2<(j+FIk8ls7)WJ6CjUu#r5PoXxQs4b)mZza<8=v{o)VlLRM<9yw^0En#tXAj`Sylxvki{<1DPe^ zhjHwx^;c8tb?Vr$6ZB;$Ff$+3(*oinbwpN-#F)bTsXq@Sm?43MC#jQ~`F|twI=7oC zH4TJtu#;ngRA|Y~w5N=UfMZi?s0%ZmKUFTAye&6Y*y-%c1oD3yQ%IF2q2385Zl+=> zfz=o`Bedy|U;oxbyb^rB9ixG{Gb-{h$U0hVe`J;{ql!s_OJ_>>eoQn(G6h7+b^P48 zG<=Wg2;xGD-+d@UMZ!c;0>#3nws$9kIDkK13IfloGT@s14AY>&>>^#>`PT7GV$2Hp zN<{bN*ztlZu_%W=&3+=#3bE(mka6VoHEs~0BjZ$+=0`a@R$iaW)6>wp2w)=v2@|2d z%?34!+iOc5S@;AAC4hELWLH56RGxo4jw8MDMU0Wk2k_G}=Vo(>eRFo(g3@HjG|`H3 zm8b*dK=moM*oB<)*A$M9!!5o~4U``e)wxavm@O_R(`P|u%9^LGi(_%IF<6o;NLp*0 zKsfZ0#24GT8(G`i4UvoMh$^;kOhl?`0yNiyrC#HJH=tqOH^T_d<2Z+ zeN>Y9Zn!X4*DMCK^o75Zk2621bdmV7Rx@AX^alBG4%~;G_vUoxhfhFRlR&+3WwF^T zaL)8xPq|wCZoNT^>3J0K?e{J-kl+hu2rZI>CUv#-z&u@`hjeb+bBZ>bcciQVZ{SbW zez04s9oFEgc8Z+Kp{XFX`MVf-s&w9*dx7wLen(_@y34}Qz@&`$2+osqfxz4&d}{Ql z*g1ag00Gu+$C`0avds{Q65BfGsu9`_`dML*rX~hyWIe$T>CsPRoLIr%MTk3pJ^2zH1qub1MBzPG}PO;Wmav9w%F7?%l=xIf#LlP`! z_Nw;xBQY9anH5-c8A4mME}?{iewjz(Sq-29r{fV;Fc>fv%0!W@(+{={Xl-sJ6aMoc z)9Q+$bchoTGTyWU_oI19!)bD=IG&OImfy;VxNXoIO2hYEfO~MkE#IXTK(~?Z&!ae! zl8z{D&2PC$Q*OBC(rS~-*-GHNJ6AC$@eve>LB@Iq;jbBZj`wk4|LGogE||Ie=M5g= z9d`uYQ1^Sr_q2wmZE>w2WG)!F%^KiqyaDtIAct?}D~JP4shTJy5Bg+-(EA8aXaxbd~BKMtTf2iQ69jD1o* zZF9*S3!v-TdqwK$%&?91Sh2=e63;X0Lci@n7y3XOu2ofyL9^-I767eHESAq{m+@*r zbVDx!FQ|AjT;!bYsXv8ilQjy~Chiu&HNhFXt3R_6kMC8~ChEFqG@MWu#1Q1#=~#ix zrkHpJre_?#r=N0wv`-7cHHqU`phJX2M_^{H0~{VP79Dv{6YP)oA1&TSfKPEPZn2)G z9o{U1huZBLL;Tp_0OYw@+9z(jkrwIGdUrOhKJUbwy?WBt zlIK)*K0lQCY0qZ!$%1?3A#-S70F#YyUnmJF*`xx?aH5;gE5pe-15w)EB#nuf6B*c~ z8Z25NtY%6Wlb)bUA$w%HKs5$!Z*W?YKV-lE0@w^{4vw;J>=rn?u!rv$&eM+rpU6rc=j9>N2Op+C{D^mospMCjF2ZGhe4eADA#skp2EA26%p3Ex9wHW8l&Y@HX z$Qv)mHM}4*@M*#*ll5^hE9M^=q~eyWEai*P;4z<9ZYy!SlNE5nlc7gm;M&Q zKhKE4d*%A>^m0R?{N}y|i6i^k>^n4(wzKvlQeHq{l&JuFD~sTsdhs`(?lFK@Q{pU~ zb!M3c@*3IwN1RUOVjY5>uT+s-2QLWY z4T2>fiSn>>Fob+%B868-v9D@AfWr#M8eM6w#eAlhc#zk6jkLxGBGk`E3$!A@*am!R zy>29&ptYK6>cvP`b!syNp)Q$0UOW|-O@)8!?94GOYF_}+zlW%fCEl|Tep_zx05g6q z>tp47e-&R*hSNe{6{H!mL?+j$c^TXT{C&@T-xIaesNCl05 z9SLb@q&mSb)I{VXMaiWa3PWj=Ed!>*GwUe;^|uk=Pz$njNnfFY^MM>E?zqhf6^{}0 zx&~~dA5#}1ig~7HvOQ#;d9JZBeEQ+}-~v$at`m!(ai z$w(H&mWCC~;PQ1$%iuz3`>dWeb3_p}X>L2LK%2l59Tyc}4m0>9A!8rhoU3m>i2+hl zx?*qs*c^j}+WPs>&v1%1Ko8_ivAGIn@QK7A`hDz-Emkcgv2@wTbYhkiwX2l=xz*XG zaiNg+j4F-I>9v+LjosI-QECrtKjp&0T@xIMKVr+&)gyb4@b3y?2CA?=ooN zT#;rU86WLh(e@#mF*rk(NV-qSIZyr z$6!ZUmzD)%yO-ot`rw3rp6?*_l*@Z*IB0xn4|BGPWHNc-1ZUnNSMWmDh=EzWJRP`) zl%d%J613oXzh5;VY^XWJi{lB`f#u+ThvtP7 zq(HK<4>tw(=yzSBWtYO}XI`S1pMBe3!jFxBHIuwJ(@%zdQFi1Q_hU2eDuHqXte7Ki zOV55H2D6u#4oTfr7|u*3p75KF&jaLEDpxk!4*bhPc%mpfj)Us3XIG3 zIKMX^s^1wt8YK7Ky^UOG=w!o5e7W-<&c|fw2{;Q11vm@J{)@N3-p1U>!0~sKWHaL= zWV(0}1IIyt1p%=_-Fe5Kfzc71wg}`RDDntVZv;4!=&XXF-$48jS0Sc;eDy@Sg;+{A zFStc{dXT}kcIjMXb4F7MbX~2%i;UrBxm%qmLKb|2=?uPr00-$MEUIGR5+JG2l2Nq` zkM{{1RO_R)+8oQ6x&-^kCj)W8Z}TJjS*Wm4>hf+4#VJP)OBaDF%3pms7DclusBUw} z{ND#!*I6h85g6DzNvdAmnwWY{&+!KZM4DGzeHI?MR@+~|su0{y-5-nICz_MIT_#FE zm<5f3zlaKq!XyvY3H`9s&T};z!cK}G%;~!rpzk9-6L}4Rg7vXtKFsl}@sT#U#7)x- z7UWue5sa$R>N&b{J61&gvKcKlozH*;OjoDR+elkh|4bJ!_3AZNMOu?n9&|L>OTD78 z^i->ah_Mqc|Ev)KNDzfu1P3grBIM#%`QZqj5W{qu(HocQhjyS;UINoP`{J+DvV?|1 z_sw6Yr3z6%e7JKVDY<$P=M)dbk@~Yw9|2!Cw!io3%j92wTD!c^e9Vj+7VqXo3>u#= zv#M{HHJ=e$X5vQ>>ML?E8#UlmvJgTnb73{PSPTf*0)mcj6C z{KsfUbDK|F$E(k;ER%8HMdDi`=BfpZzP3cl5yJHu;v^o2FkHNk;cXc17tL8T!CsYI zfeZ6sw@;8ia|mY_AXjCS?kUfxdjDB28)~Tz1dGE|{VfBS9`0m2!m1yG?hR})er^pl4c@9Aq+|}ZlDaHL)K$O| z%9Jp-imI-Id0|(d5{v~w6mx)tUKfbuVD`xNt04Mry%M+jXzE>4(TBsx#&=@wT2Vh) z1yeEY&~17>0%P(eHP0HB^|7C+WJxQBTG$uyOWY@iDloRIb-Cf!p<{WQHR!422#F34 zG`v|#CJ^G}y9U*7jgTlD{D&y$Iv{6&PYG>{Ixg$pGk?lWrE#PJ8KunQC@}^6OP!|< zS;}p3to{S|uZz%kKe|;A0bL0XxPB&Q{J(9PyX`+Kr`k~r2}yP^ND{8!v7Q1&vtk& z2Y}l@J@{|2`oA%sxvM9i0V+8IXrZ4;tey)d;LZI70Kbim<4=WoTPZy=Yd|34v#$Kh zx|#YJ8s`J>W&jt#GcMpx84w2Z3ur-rK7gf-p5cE)=w1R2*|0mj12hvapuUWM0b~dG zMg9p8FmAZI@i{q~0@QuY44&mMUNXd7z>U58shA3o`p5eVLpq>+{(<3->DWuSFVZwC zxd50Uz(w~LxC4}bgag#q#NNokK@yNc+Q|Ap!u>Ddy+df>v;j@I12CDNN9do+0^n8p zMQs7X#+FVF0C5muGfN{r0|Nkql%BQT|K(DDNdR2pzM=_ea5+GO|J67`05AV92t@4l z0Qno0078PIHdaQGHZ~Scw!dzgqjK~3B7kf>BcP__&lLyU(cu3B^uLo%{j|Mb0NR)tkeT7Hcwp4O# z)yzu>cvG(d9~0a^)eZ;;%3ksk@F&1eEBje~ zW+-_s)&RgiweQc!otF>4%vbXKaOU41{!hw?|2`Ld3I8$&#WOsq>EG)1ANb!{N4z9@ zsU!bPG-~-bqCeIDzo^Q;gnucB{tRzm{ZH^Orphm2U+REA!*<*J6YQV83@&xoDl%#wnl5qcBqCcAF-vX5{30}(oJrnSH z{RY85hylK2dMOh2%oO1J8%)0?8TOL%rS8)+CsDv}aQ>4D)Jv+DLK)9gI^n-T^$)Tc zFPUD75qJm!Y-KBqj;JP4dV4 z`X{lGmn<)1IGz330}s}Jrjtf{(lnuuNHe5(ezA(pYa=1|Ff-LhPFK8 zyJh_b{yzu0yll6ZkpRzRjezyYivjyjW7QwO;@6X`m;2Apn2EK2!~7S}-*=;5*7K$B z`x(=!^?zgj(-`&ApZJXI09aDLXaT@<;CH=?fBOY5d|b~wBA@@p^K#nxr`)?i?SqTupI_PJ(A3cx`z~9mX_*)>L F{|7XC?P&l2 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6975649 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Apr 11 21:03:49 EEST 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle new file mode 100644 index 0000000..576eb20 --- /dev/null +++ b/gradlescripts/versions.gradle @@ -0,0 +1,28 @@ +project.ext { + androidx_core_version = "1.3.2" + androidx_appcompat_version = "1.2.0" + androidx_material_version = "1.3.0" + androidx_constraintlayout_version = "2.0.4" + androidx_livedata_version = "2.3.1" + androidx_swiperefreshlayout_version = "1.1.0" + androidx_room_version = "2.2.6" + + coroutines_version = "1.4.3" + koin_version = "2.2.2" + coil_version = "1.1.1" + retrofit_version = "2.9.0" + okhttp_version = "4.9.1" + moshi_version = "1.11.0" + + testing_androidx_code_version = "1.3.0" + testing_androidx_junit_version = "1.1.2" + testing_androidx_arch_core_version = "2.1.0" + testing_livedata_version = "1.1.2" + testing_kotlin_mockito_version = "3.1.0" + testing_junit5_version = "5.7.0" + testing_json_assert_version = "1.5.0" + testing_junit4_version = "4.13.2" + testing_robolectric_version = "4.5.1" + testing_espresso_version = "3.3.0" + testing_okhttp3_idling_resource_version = "1.0.0" +} \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mockserver/.gitignore b/mockserver/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/mockserver/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/mockserver/build.gradle b/mockserver/build.gradle new file mode 100644 index 0000000..acda750 --- /dev/null +++ b/mockserver/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java-library' + id 'kotlin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + api project(":model") + + api "com.squareup.okhttp3:mockwebserver:$okhttp_version" + api "com.squareup.okhttp3:okhttp-tls:$okhttp_version" + + implementation "org.skyscreamer:jsonassert:$testing_json_assert_version" + implementation "junit:junit:$testing_junit4_version" + +} \ No newline at end of file diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ContentData.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ContentData.kt new file mode 100644 index 0000000..f187e4c --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ContentData.kt @@ -0,0 +1,45 @@ +package org.fnives.test.showcase.network.mockserver + +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.session.Session +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario + +object ContentData { + + /** + * Returned for [ContentScenario.Success] + */ + val contentSuccess: List = listOf( + Content(ContentId("1"), "title_1", "says_1", ImageUrl("img_1")), + Content(ContentId("2"), "title_2", "says_2", ImageUrl("img_2")), + Content(ContentId("3"), "title_3", "says_3", ImageUrl("img_3")) + ) + + /** + * Returned for [ContentScenario.SuccessWithMissingFields] + */ + val contentSuccessWithMissingFields: List = listOf( + Content(ContentId("1"), "title_1", "says_1", ImageUrl("img_1")) + ) + + /** + * Returned for [AuthScenario.Success] + */ + val loginSuccessResponse = Session("login-access", "login-refresh") + + /** + * Expected for [AuthScenario.Success] + */ + fun createExpectedLoginRequestJson(username: String, password: String) = + AuthRequestMatchingChecker.createExpectedJson(username = username, password = password) + + /** + * Returned for [RefreshTokenScenario.Success] + */ + val refreshSuccessResponse = Session("refreshed-access", "refreshed-refresh") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/MockServerScenarioSetup.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/MockServerScenarioSetup.kt new file mode 100644 index 0000000..6946a59 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/MockServerScenarioSetup.kt @@ -0,0 +1,95 @@ +package org.fnives.test.showcase.network.mockserver + +import okhttp3.mockwebserver.MockWebServer +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import java.net.InetAddress + +class MockServerScenarioSetup internal constructor( + private val networkDispatcher: NetworkDispatcher, + private val scenarioToRequestScenario: ScenarioToRequestScenario +) { + + constructor() : this(NetworkDispatcher(), ScenarioToRequestScenario()) + + lateinit var mockWebServer: MockWebServer + private set + var clientCertificates: HandshakeCertificates? = null + private set + + fun start(useHttps: Boolean) { + val mockWebServer = MockWebServer().also { this.mockWebServer = it } + if (useHttps) { + clientCertificates = mockWebServer.useHttps() + } + mockWebServer.dispatcher = networkDispatcher + mockWebServer.start(InetAddress.getLocalHost(), PORT) + } + + /** + * Sets AuthScenario to what to return to the Refresh token request + * @param validateArguments if true the request type / body / headers will be verified, otherwise just the path + */ + fun setScenario(authScenario: AuthScenario, validateArguments: Boolean = true) = apply { + networkDispatcher.set( + NetworkDispatcher.ScenarioType.AUTH, + scenarioToRequestScenario.get(authScenario, validateArguments) + ) + } + + /** + * Sets Scenario to what to return to the Refresh token request + * @param validateArguments if true the request type / body / headers will be verified, otherwise just the path + */ + fun setScenario(refreshTokenScenario: RefreshTokenScenario, validateArguments: Boolean = true) = apply { + networkDispatcher.set( + NetworkDispatcher.ScenarioType.REFRESH, + scenarioToRequestScenario.get(refreshTokenScenario, validateArguments) + ) + } + + /** + * Sets ContentScenario to what to return to the Refresh token request + * @param validateArguments if true the request type / body / headers will be verified, otherwise just the path + */ + fun setScenario(contentScenario: ContentScenario, validateArguments: Boolean = true) = apply { + networkDispatcher.set( + NetworkDispatcher.ScenarioType.CONTENT, + scenarioToRequestScenario.get(contentScenario, validateArguments) + ) + } + + fun takeRequest() = mockWebServer.takeRequest() + + fun stop() { + mockWebServer.shutdown() + } + + companion object { + const val PORT: Int = 7335 + val HTTP_BASE_URL get() = "http://${InetAddress.getLocalHost().hostName}" + val HTTPS_BASE_URL get() = "https://localhost" + + private fun MockWebServer.useHttps(): HandshakeCertificates { + val localhost = InetAddress.getByName("localhost").canonicalHostName + val localhostCertificate = HeldCertificate.Builder() + .addSubjectAlternativeName(localhost) + .build() + + val serverCertificates = HandshakeCertificates.Builder() + .heldCertificate(localhostCertificate) + .build() + + useHttps(serverCertificates.sslSocketFactory(), false) + + val clientCertificates = HandshakeCertificates.Builder() + .addTrustedCertificate(localhostCertificate.certificate) + .build() + + return clientCertificates + } + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/NetworkDispatcher.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/NetworkDispatcher.kt new file mode 100644 index 0000000..f70ee8c --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/NetworkDispatcher.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.network.mockserver + +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenario +import org.fnives.test.showcase.network.mockserver.scenario.general.NotFoundRequestScenario + +internal class NetworkDispatcher : Dispatcher() { + + private var scenarios: Map = emptyMap() + + override fun dispatch(request: RecordedRequest): MockResponse = + scenarios.values + .asSequence() + .mapNotNull { it.getResponse(request) } + .firstOrNull() + ?: NotFoundRequestScenario.getResponse(request) + + fun set(type: ScenarioType, scenario: RequestScenario) { + scenarios = scenarios.plus(type to scenario) + } + + enum class ScenarioType { + AUTH, + REFRESH, + CONTENT + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ScenarioToRequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ScenarioToRequestScenario.kt new file mode 100644 index 0000000..f699cda --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ScenarioToRequestScenario.kt @@ -0,0 +1,80 @@ +package org.fnives.test.showcase.network.mockserver + +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenario +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenarioChain +import org.fnives.test.showcase.network.mockserver.scenario.SpecificRequestScenario +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.network.mockserver.scenario.auth.CreateAuthInvalidCredentialsResponse +import org.fnives.test.showcase.network.mockserver.scenario.auth.CreateAuthSuccessResponse +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.content.CreateContentSuccessResponse +import org.fnives.test.showcase.network.mockserver.scenario.content.CreateContentSuccessWithMissingFields +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateGeneralErrorResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateGenericSuccessResponseByJson +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateMalformedJsonSuccessResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateUnauthorizedResponse +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.CreateRefreshResponse +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario + +internal class ScenarioToRequestScenario { + + fun get(authScenario: AuthScenario, validateArguments: Boolean): RequestScenario = + wrap(authScenario, ::convert, validateArguments) + + fun get(contentScenario: ContentScenario, validateArguments: Boolean): RequestScenario = + wrap(contentScenario, ::convert, validateArguments) + + fun get(refreshTokenScenario: RefreshTokenScenario, validateArguments: Boolean): RequestScenario = + wrap(refreshTokenScenario, ::convert, validateArguments) + + private fun convert(validateArguments: Boolean, authScenario: AuthScenario): RequestScenario { + val createResponse = when (authScenario) { + is AuthScenario.GenericError -> CreateGeneralErrorResponse() + is AuthScenario.InvalidCredentials -> CreateAuthInvalidCredentialsResponse() + is AuthScenario.Success -> CreateAuthSuccessResponse() + is AuthScenario.MalformedJsonAsSuccessResponse -> CreateMalformedJsonSuccessResponse() + is AuthScenario.UnexpectedJsonAsSuccessResponse -> CreateGenericSuccessResponseByJson("[]") + } + val requestMatchingChecker = AuthRequestMatchingChecker(authScenario, validateArguments) + return SpecificRequestScenario(requestMatchingChecker, createResponse) + } + + private fun convert(validateArguments: Boolean, contentScenario: ContentScenario): RequestScenario { + val createResponse = when (contentScenario) { + is ContentScenario.Error -> CreateGeneralErrorResponse() + is ContentScenario.Success -> CreateContentSuccessResponse() + is ContentScenario.SuccessWithMissingFields -> CreateContentSuccessWithMissingFields() + is ContentScenario.Unauthorized -> CreateUnauthorizedResponse() + is ContentScenario.MalformedJsonAsSuccessResponse -> CreateMalformedJsonSuccessResponse() + is ContentScenario.UnexpectedJsonAsSuccessResponse -> CreateGenericSuccessResponseByJson("{}") + } + val requestMatchingChecker = ContentRequestMatchingChecker(contentScenario, validateArguments) + return SpecificRequestScenario(requestMatchingChecker, createResponse) + } + + private fun convert(validateArguments: Boolean, refreshTokenScenario: RefreshTokenScenario): RequestScenario { + val contentResponse = when (refreshTokenScenario) { + RefreshTokenScenario.Error -> CreateGeneralErrorResponse() + RefreshTokenScenario.Success -> CreateRefreshResponse() + RefreshTokenScenario.UnexpectedJsonAsSuccessResponse -> CreateGenericSuccessResponseByJson("{}") + RefreshTokenScenario.MalformedJson -> CreateMalformedJsonSuccessResponse() + } + val requestMatchingChecker = RefreshRequestMatchingChecker(validateArguments) + return SpecificRequestScenario(requestMatchingChecker, contentResponse) + } + + private fun > wrap( + scenario: T, + convert: (Boolean, T) -> RequestScenario, + validateArguments: Boolean + ): RequestScenario { + val requestScenario = convert(validateArguments, scenario) + val previousScenario = scenario.previousScenario ?: return requestScenario + + return RequestScenarioChain(current = convert(validateArguments, previousScenario), next = requestScenario) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestMatchingChecker.kt new file mode 100644 index 0000000..0f97513 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestMatchingChecker.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONException +import kotlin.jvm.Throws + +internal interface RequestMatchingChecker { + + @Throws(AssertionError::class, JSONException::class) + fun isValidRequest(request: RecordedRequest): Boolean +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenario.kt new file mode 100644 index 0000000..b817e0b --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenario.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest + +internal interface RequestScenario { + + fun getResponse(request: RecordedRequest): MockResponse? +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenarioChain.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenarioChain.kt new file mode 100644 index 0000000..8b30efe --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenarioChain.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest + +internal class RequestScenarioChain( + private val current: RequestScenario, + private val next: RequestScenario +) : RequestScenario { + + private var isConsumed = false + + override fun getResponse(request: RecordedRequest): MockResponse? = + if (isConsumed) { + next.getResponse(request) + } else { + current.getResponse(request)?.also { isConsumed = true } + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/SpecificRequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/SpecificRequestScenario.kt new file mode 100644 index 0000000..97d1ddf --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/SpecificRequestScenario.kt @@ -0,0 +1,34 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.json.JSONException + +internal class SpecificRequestScenario( + private val requestMatchingChecker: RequestMatchingChecker, + private val createResponse: CreateResponse +) : RequestScenario { + + override fun getResponse(request: RecordedRequest): MockResponse? = wrapExceptionsIntoMockResponse { + if (requestMatchingChecker.isValidRequest(request)) { + createResponse.getResponse() + } else { + null + } + } + + private fun wrapExceptionsIntoMockResponse(responseFactory: () -> MockResponse?): MockResponse? = + try { + responseFactory() + } catch (jsonException: JSONException) { + MockResponse().setBody("JSONException while asserting your request, message: ${jsonException.message}") + .setResponseCode(400) + } catch (assertionError: AssertionError) { + MockResponse().setBody("AssertionError while asserting your request, message: ${assertionError.message}") + .setResponseCode(400) + } catch (throwable: Throwable) { + MockResponse().setBody("Unexpected Exception while asserting your request, message: ${throwable.message}") + .setResponseCode(400) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthRequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthRequestMatchingChecker.kt new file mode 100644 index 0000000..e37f291 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthRequestMatchingChecker.kt @@ -0,0 +1,39 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestMatchingChecker +import org.junit.Assert +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode + +internal class AuthRequestMatchingChecker( + private val authScenario: AuthScenario, + private val validateArguments: Boolean +) : RequestMatchingChecker { + + override fun isValidRequest(request: RecordedRequest): Boolean { + if (request.path != "/login") return false + if (!validateArguments) return true + + Assert.assertEquals("POST", request.method) + Assert.assertEquals("Android", request.getHeader("Platform")) + Assert.assertEquals(null, request.getHeader("Authorization")) + val expectedJson = createExpectedJson( + username = authScenario.username, + password = authScenario.password + ) + JSONAssert.assertEquals(expectedJson, request.body.readUtf8(), JSONCompareMode.LENIENT) + + return true + } + + companion object { + internal fun createExpectedJson(username: String, password: String): String = + """ + { + "username": "$username", + "password": "$password" + } + """.trimIndent() + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthScenario.kt new file mode 100644 index 0000000..c2418db --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthScenario.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario + +sealed class AuthScenario : GenericScenario() { + + abstract val username: String + abstract val password: String + + class Success(override val username: String, override val password: String) : AuthScenario() + class InvalidCredentials(override val username: String, override val password: String) : AuthScenario() + class GenericError(override val username: String, override val password: String) : AuthScenario() + class UnexpectedJsonAsSuccessResponse(override val username: String, override val password: String) : AuthScenario() + class MalformedJsonAsSuccessResponse(override val username: String, override val password: String) : AuthScenario() +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthInvalidCredentialsResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthInvalidCredentialsResponse.kt new file mode 100644 index 0000000..5087e62 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthInvalidCredentialsResponse.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse + +internal class CreateAuthInvalidCredentialsResponse : CreateResponse { + + override fun getResponse(): MockResponse = + MockResponse().setResponseCode(400).setBody("{}") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthSuccessResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthSuccessResponse.kt new file mode 100644 index 0000000..0daa375 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthSuccessResponse.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateAuthSuccessResponse : CreateResponse { + + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/auth/success_response_login.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentRequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentRequestMatchingChecker.kt new file mode 100644 index 0000000..095f4f0 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentRequestMatchingChecker.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.RequestMatchingChecker +import org.junit.Assert + +internal class ContentRequestMatchingChecker( + private val contentScenario: ContentScenario, + private val validateArguments: Boolean +) : RequestMatchingChecker { + + override fun isValidRequest(request: RecordedRequest): Boolean { + if (request.path != "/content") return false + if (!validateArguments) return true + + Assert.assertEquals("GET", request.method) + Assert.assertEquals("Android", request.getHeader("Platform")) + val expectedToken = if (contentScenario.usingRefreshedToken) { + ContentData.refreshSuccessResponse.accessToken + } else { + ContentData.loginSuccessResponse.accessToken + } + Assert.assertEquals(expectedToken, request.getHeader("Authorization")) + Assert.assertEquals("", request.body.readUtf8()) + + return true + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentScenario.kt new file mode 100644 index 0000000..b8e4949 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentScenario.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario + +sealed class ContentScenario : GenericScenario() { + + abstract val usingRefreshedToken: Boolean + + class Success(override val usingRefreshedToken: Boolean) : ContentScenario() + class SuccessWithMissingFields(override val usingRefreshedToken: Boolean) : ContentScenario() + class Unauthorized(override val usingRefreshedToken: Boolean) : ContentScenario() + class Error(override val usingRefreshedToken: Boolean) : ContentScenario() + class UnexpectedJsonAsSuccessResponse(override val usingRefreshedToken: Boolean) : ContentScenario() + class MalformedJsonAsSuccessResponse(override val usingRefreshedToken: Boolean) : ContentScenario() +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessResponse.kt new file mode 100644 index 0000000..da698ef --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessResponse.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateContentSuccessResponse : CreateResponse { + + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/content/success_response_content.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessWithMissingFields.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessWithMissingFields.kt new file mode 100644 index 0000000..12652c1 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessWithMissingFields.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateContentSuccessWithMissingFields : CreateResponse { + + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/content/content_missing_field_response.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGeneralErrorResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGeneralErrorResponse.kt new file mode 100644 index 0000000..b54fed7 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGeneralErrorResponse.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateGeneralErrorResponse : CreateResponse { + override fun getResponse(): MockResponse = MockResponse().setResponseCode(500).setBody("{}") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGenericSuccessResponseByJson.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGenericSuccessResponseByJson.kt new file mode 100644 index 0000000..3d57413 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGenericSuccessResponseByJson.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateGenericSuccessResponseByJson(val json: String) : CreateResponse { + override fun getResponse(): MockResponse = MockResponse().setResponseCode(200).setBody(json) +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateMalformedJsonSuccessResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateMalformedJsonSuccessResponse.kt new file mode 100644 index 0000000..ec8ea9d --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateMalformedJsonSuccessResponse.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateMalformedJsonSuccessResponse : CreateResponse { + override fun getResponse(): MockResponse = MockResponse().setResponseCode(200).setBody("[") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateResponse.kt new file mode 100644 index 0000000..b03e846 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateResponse.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal interface CreateResponse { + + fun getResponse(): MockResponse +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateUnauthorizedResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateUnauthorizedResponse.kt new file mode 100644 index 0000000..a6bf0d3 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateUnauthorizedResponse.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateUnauthorizedResponse : CreateResponse { + override fun getResponse(): MockResponse = + MockResponse().setResponseCode(401).setBody("{}") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/GenericScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/GenericScenario.kt new file mode 100644 index 0000000..772775b --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/GenericScenario.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.network.mockserver.scenario.general + +@Suppress("UnnecessaryAbstractClass") +abstract class GenericScenario> internal constructor() { + + internal var previousScenario: T? = null + private set + + @Suppress("UNCHECKED_CAST") + fun then(scenario: T): T { + scenario.previousScenario = this as T + return scenario + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/NotFoundRequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/NotFoundRequestScenario.kt new file mode 100644 index 0000000..fd97a98 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/NotFoundRequestScenario.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.network.mockserver.scenario.general + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenario + +internal object NotFoundRequestScenario : RequestScenario { + override fun getResponse(request: RecordedRequest): MockResponse = + MockResponse().setResponseCode(404).setBody("Not Found") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/CreateRefreshResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/CreateRefreshResponse.kt new file mode 100644 index 0000000..5846053 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/CreateRefreshResponse.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.mockserver.scenario.refresh + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateRefreshResponse : CreateResponse { + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/refresh/success_response_refresh.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshRequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshRequestMatchingChecker.kt new file mode 100644 index 0000000..16a5379 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshRequestMatchingChecker.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.network.mockserver.scenario.refresh + +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestMatchingChecker +import org.junit.Assert + +internal class RefreshRequestMatchingChecker(val validateArguments: Boolean) : RequestMatchingChecker { + override fun isValidRequest(request: RecordedRequest): Boolean { + if (request.path != "/login/login-refresh" && request.path != "/login/refreshed-refresh") { + return false + } + if (!validateArguments) return true + + Assert.assertEquals("PUT", request.method) + Assert.assertEquals("Android", request.getHeader("Platform")) + Assert.assertEquals(null, request.getHeader("Authorization")) + Assert.assertEquals("", request.body.readUtf8()) + + return true + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshTokenScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshTokenScenario.kt new file mode 100644 index 0000000..0234247 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshTokenScenario.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.network.mockserver.scenario.refresh + +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario + +sealed class RefreshTokenScenario : GenericScenario() { + object Success : RefreshTokenScenario() + object Error : RefreshTokenScenario() + object UnexpectedJsonAsSuccessResponse : RefreshTokenScenario() + object MalformedJson : RefreshTokenScenario() +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/utils/ResourceUtils.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/utils/ResourceUtils.kt new file mode 100644 index 0000000..fd98161 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/utils/ResourceUtils.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.network.mockserver.utils + +import java.io.BufferedReader +import java.io.InputStreamReader + +internal fun Any.readResourceFile(filePath: String): String = try { + BufferedReader(InputStreamReader(this.javaClass.classLoader.getResourceAsStream(filePath)!!)) + .readLines().joinToString("\n") +} catch (nullPointerException: NullPointerException) { + throw IllegalArgumentException("$filePath file not found!", nullPointerException) +} + +private fun BufferedReader.readLines(): List { + val result = mutableListOf() + use { + do { + readLine()?.let(result::add) ?: return result + } while (true) + } +} diff --git a/mockserver/src/main/resources/response/auth/success_response_login.json b/mockserver/src/main/resources/response/auth/success_response_login.json new file mode 100644 index 0000000..ba930ef --- /dev/null +++ b/mockserver/src/main/resources/response/auth/success_response_login.json @@ -0,0 +1,4 @@ +{ + "accessToken": "login-access", + "refreshToken": "login-refresh" +} \ No newline at end of file diff --git a/mockserver/src/main/resources/response/content/content_missing_field_response.json b/mockserver/src/main/resources/response/content/content_missing_field_response.json new file mode 100644 index 0000000..63eda7f --- /dev/null +++ b/mockserver/src/main/resources/response/content/content_missing_field_response.json @@ -0,0 +1,13 @@ +[ + { + "id": "1", + "title": "title_1", + "says": "says_1", + "image": "img_1" + }, + { + "id": "2", + "title": "title_2", + "says": "says_2" + } +] \ No newline at end of file diff --git a/mockserver/src/main/resources/response/content/success_response_content.json b/mockserver/src/main/resources/response/content/success_response_content.json new file mode 100644 index 0000000..f68e644 --- /dev/null +++ b/mockserver/src/main/resources/response/content/success_response_content.json @@ -0,0 +1,20 @@ +[ + { + "id": "1", + "title": "title_1", + "says": "says_1", + "image": "img_1" + }, + { + "id": "2", + "title": "title_2", + "says": "says_2", + "image": "img_2" + }, + { + "id": "3", + "title": "title_3", + "says": "says_3", + "image": "img_3" + } +] \ No newline at end of file diff --git a/mockserver/src/main/resources/response/refresh/success_response_refresh.json b/mockserver/src/main/resources/response/refresh/success_response_refresh.json new file mode 100644 index 0000000..dee8c87 --- /dev/null +++ b/mockserver/src/main/resources/response/refresh/success_response_refresh.json @@ -0,0 +1,4 @@ +{ + "accessToken": "refreshed-access", + "refreshToken": "refreshed-refresh" +} \ No newline at end of file diff --git a/model/.gitignore b/model/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/model/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/model/build.gradle b/model/build.gradle new file mode 100644 index 0000000..505234c --- /dev/null +++ b/model/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'java-library' + id 'kotlin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +compileKotlin { + kotlinOptions { + freeCompilerArgs = ["-Xinline-classes"] + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} \ No newline at end of file diff --git a/model/src/main/java/org/fnives/test/showcase/model/auth/LoginCredentials.kt b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginCredentials.kt new file mode 100644 index 0000000..5d2ec47 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginCredentials.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.auth + +data class LoginCredentials(val username: String, val password: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/auth/LoginStatus.kt b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginStatus.kt new file mode 100644 index 0000000..de5891c --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginStatus.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.model.auth + +enum class LoginStatus { + SUCCESS, INVALID_CREDENTIALS, INVALID_USERNAME, INVALID_PASSWORD +} diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/Content.kt b/model/src/main/java/org/fnives/test/showcase/model/content/Content.kt new file mode 100644 index 0000000..2a01267 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/Content.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +data class Content(val id: ContentId, val title: String, val description: String, val imageUrl: ImageUrl) diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/ContentId.kt b/model/src/main/java/org/fnives/test/showcase/model/content/ContentId.kt new file mode 100644 index 0000000..c0b2c83 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/ContentId.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +inline class ContentId(val id: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/FavouriteContent.kt b/model/src/main/java/org/fnives/test/showcase/model/content/FavouriteContent.kt new file mode 100644 index 0000000..66793df --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/FavouriteContent.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +data class FavouriteContent(val content: Content, val isFavourite: Boolean) diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/ImageUrl.kt b/model/src/main/java/org/fnives/test/showcase/model/content/ImageUrl.kt new file mode 100644 index 0000000..b304fa8 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/ImageUrl.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +inline class ImageUrl(val url: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/network/BaseUrl.kt b/model/src/main/java/org/fnives/test/showcase/model/network/BaseUrl.kt new file mode 100644 index 0000000..1b8a6e4 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/network/BaseUrl.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.network + +inline class BaseUrl(val baseUrl: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/session/Session.kt b/model/src/main/java/org/fnives/test/showcase/model/session/Session.kt new file mode 100644 index 0000000..dd9c331 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/session/Session.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.session + +data class Session(val accessToken: String, val refreshToken: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/shared/Answer.kt b/model/src/main/java/org/fnives/test/showcase/model/shared/Answer.kt new file mode 100644 index 0000000..63d5736 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/shared/Answer.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.model.shared + +sealed class Answer { + data class Success(val data: T) : Answer() + data class Error(val error: Throwable) : Answer() +} diff --git a/model/src/main/java/org/fnives/test/showcase/model/shared/Resource.kt b/model/src/main/java/org/fnives/test/showcase/model/shared/Resource.kt new file mode 100644 index 0000000..f3b5637 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/shared/Resource.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.model.shared + +sealed class Resource { + data class Success(val data: T) : Resource() + data class Error(val error: Throwable) : Resource() + class Loading : Resource() { + override fun equals(other: Any?): Boolean = + javaClass == other?.javaClass + + override fun hashCode(): Int = Loading::class.java.hashCode() + + override fun toString(): String = "Resource.Loading()" + } + + abstract override fun equals(other: Any?): Boolean + + abstract override fun hashCode(): Int + + abstract override fun toString(): String +} diff --git a/network/build.gradle b/network/build.gradle new file mode 100644 index 0000000..9a11548 --- /dev/null +++ b/network/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'java-library' + id 'kotlin' + id 'kotlin-kapt' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +test { + useJUnitPlatform() + testLogging { + events 'started', 'passed', 'skipped', 'failed' + exceptionFormat "full" + showStandardStreams true + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + 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 "org.koin:koin-core:$koin_version" + + api project(":model") + + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version" + testImplementation "org.junit.jupiter:junit-jupiter-engine:5.7.0" + testImplementation project(':mockserver') + testImplementation "org.koin:koin-test:$koin_version" + testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" +} \ No newline at end of file diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/LoginErrorConverter.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginErrorConverter.kt new file mode 100644 index 0000000..49a588b --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginErrorConverter.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.model.LoginResponse +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.shared.ExceptionWrapper +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import retrofit2.HttpException +import retrofit2.Response + +internal class LoginErrorConverter { + + @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/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSource.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSource.kt new file mode 100644 index 0000000..7401011 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSource.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException + +interface LoginRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun login(credentials: LoginCredentials): LoginStatusResponses +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceImpl.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceImpl.kt new file mode 100644 index 0000000..248ffde --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceImpl.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.shared.ExceptionWrapper +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException + +internal class LoginRemoteSourceImpl 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/network/src/main/java/org/fnives/test/showcase/network/auth/LoginService.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginService.kt new file mode 100644 index 0000000..68dda33 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginService.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.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/network/src/main/java/org/fnives/test/showcase/network/auth/model/CredentialsRequest.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/model/CredentialsRequest.kt new file mode 100644 index 0000000..4e4bd61 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/model/CredentialsRequest.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.auth.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class CredentialsRequest( + @Json(name = "username") + val user: String, + @Json(name = "password") + val password: String +) diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginResponse.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginResponse.kt new file mode 100644 index 0000000..b45413f --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginResponse.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.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/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginStatusResponses.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginStatusResponses.kt new file mode 100644 index 0000000..11caa81 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginStatusResponses.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.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/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSource.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSource.kt new file mode 100644 index 0000000..0e17bd0 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSource.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.content + +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import kotlin.jvm.Throws + +interface ContentRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun get(): List +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImpl.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImpl.kt new file mode 100644 index 0000000..d1daac8 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImpl.kt @@ -0,0 +1,26 @@ +package org.fnives.test.showcase.network.content + +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.network.shared.ExceptionWrapper + +internal class ContentRemoteSourceImpl(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/network/src/main/java/org/fnives/test/showcase/network/content/ContentResponse.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentResponse.kt new file mode 100644 index 0000000..c4c7268 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentResponse.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.network.content + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class ContentResponse( + @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/network/src/main/java/org/fnives/test/showcase/network/content/ContentService.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentService.kt new file mode 100644 index 0000000..ec6ac14 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentService.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.network.content + +import retrofit2.http.GET + +interface ContentService { + + @GET("content") + suspend fun getContent(): List +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/di/OkhttpClientExtension.kt b/network/src/main/java/org/fnives/test/showcase/network/di/OkhttpClientExtension.kt new file mode 100644 index 0000000..2d35d33 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/di/OkhttpClientExtension.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.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/network/src/main/java/org/fnives/test/showcase/network/di/createNetworkmodules.kt b/network/src/main/java/org/fnives/test/showcase/network/di/createNetworkmodules.kt new file mode 100644 index 0000000..722cc4c --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/di/createNetworkmodules.kt @@ -0,0 +1,87 @@ +package org.fnives.test.showcase.network.di + +import okhttp3.OkHttpClient +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.auth.LoginErrorConverter +import org.fnives.test.showcase.network.auth.LoginRemoteSource +import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl +import org.fnives.test.showcase.network.auth.LoginService +import org.fnives.test.showcase.network.content.ContentRemoteSource +import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl +import org.fnives.test.showcase.network.content.ContentService +import org.fnives.test.showcase.network.session.AuthenticationHeaderInterceptor +import org.fnives.test.showcase.network.session.AuthenticationHeaderUtils +import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.session.SessionAuthenticator +import org.fnives.test.showcase.network.shared.PlatformInterceptor +import org.koin.core.module.Module +import org.koin.core.qualifier.StringQualifier +import org.koin.core.scope.Scope +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +fun createNetworkModules( + baseUrl: BaseUrl, + enableLogging: Boolean, + networkSessionLocalStorageProvider: Scope.() -> NetworkSessionLocalStorage, + networkSessionExpirationListenerProvider: Scope.() -> NetworkSessionExpirationListener +): Sequence = + sequenceOf( + loginModule(), + contentModule(), + sessionlessNetworkingModule(baseUrl, enableLogging), + sessionNetworkingModule(networkSessionLocalStorageProvider, networkSessionExpirationListenerProvider) + ) + +private fun loginModule() = module { + factory { LoginRemoteSourceImpl(get(), get()) } + factory { get() } + factory { LoginErrorConverter() } + factory { get(sessionless).create(LoginService::class.java) } +} + +private fun contentModule() = module { + factory { get(session).create(ContentService::class.java) } + factory { ContentRemoteSourceImpl(get()) } + factory { get() } +} + +private fun sessionlessNetworkingModule(baseUrl: BaseUrl, enableLogging: Boolean) = module { + factory { MoshiConverterFactory.create() } + single(qualifier = sessionless, override = true) { + OkHttpClient.Builder() + .addInterceptor(PlatformInterceptor()) + .setupLogging(enableLogging) + .build() + } + single(qualifier = sessionless) { + Retrofit.Builder() + .baseUrl(baseUrl.baseUrl) + .addConverterFactory(get()) + .client(get(sessionless)) + .build() + } +} + +private fun sessionNetworkingModule( + networkSessionLocalStorageProvider: Scope.() -> NetworkSessionLocalStorage, + networkSessionExpirationListenerProvider: Scope.() -> NetworkSessionExpirationListener +) = module { + single { AuthenticationHeaderUtils(get()) } + single { networkSessionExpirationListenerProvider() } + single { networkSessionLocalStorageProvider() } + factory { SessionAuthenticator(get(), get(), get(), get()) } + single(qualifier = session) { + get(sessionless) + .newBuilder() + .authenticator(get()) + .addInterceptor(AuthenticationHeaderInterceptor(get())) + .build() + } + single(qualifier = session) { get(sessionless).newBuilder().client(get(session)).build() } +} + +private val session = StringQualifier("SESSION-NETWORKING") +private val sessionless = StringQualifier("SESSIONLESS-NETWORKING") diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderInterceptor.kt b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderInterceptor.kt new file mode 100644 index 0000000..cfcd1ce --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderInterceptor.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.session + +import okhttp3.Interceptor +import okhttp3.Response + +internal class AuthenticationHeaderInterceptor( + private val authenticationHeaderUtils: AuthenticationHeaderUtils +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(authenticationHeaderUtils.attachToken(chain.request())) +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderUtils.kt b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderUtils.kt new file mode 100644 index 0000000..cfb35c6 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderUtils.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.network.session + +import okhttp3.Request + +internal class AuthenticationHeaderUtils(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/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionExpirationListener.kt b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionExpirationListener.kt new file mode 100644 index 0000000..b8ebd78 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionExpirationListener.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.network.session + +interface NetworkSessionExpirationListener { + + fun onSessionExpired() +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionLocalStorage.kt b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionLocalStorage.kt new file mode 100644 index 0000000..5cb3c73 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionLocalStorage.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.network.session + +import org.fnives.test.showcase.model.session.Session + +interface NetworkSessionLocalStorage { + + var session: Session? +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/SessionAuthenticator.kt b/network/src/main/java/org/fnives/test/showcase/network/session/SessionAuthenticator.kt new file mode 100644 index 0000000..a5a2cf2 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/SessionAuthenticator.kt @@ -0,0 +1,34 @@ +package org.fnives.test.showcase.network.session + +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl + +internal class SessionAuthenticator( + private val networkSessionLocalStorage: NetworkSessionLocalStorage, + private val loginRemoteSource: LoginRemoteSourceImpl, + private val authenticationHeaderUtils: AuthenticationHeaderUtils, + private val networkSessionExpirationListener: NetworkSessionExpirationListener +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + if (authenticationHeaderUtils.hasToken(response.request)) { + return runBlocking { + try { + val newSession = loginRemoteSource.refresh(networkSessionLocalStorage.session?.refreshToken.orEmpty()) + 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/network/src/main/java/org/fnives/test/showcase/network/shared/ExceptionWrapper.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/ExceptionWrapper.kt new file mode 100644 index 0000000..fd9cf74 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/ExceptionWrapper.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.network.shared + +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonEncodingException +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import java.io.EOFException + +internal object ExceptionWrapper { + + @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/network/src/main/java/org/fnives/test/showcase/network/shared/PlatformInterceptor.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/PlatformInterceptor.kt new file mode 100644 index 0000000..a74f951 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/PlatformInterceptor.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.shared + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class PlatformInterceptor : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(chain.request().newBuilder().header("Platform", "Android").build()) +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/NetworkException.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/NetworkException.kt new file mode 100644 index 0000000..89086bc --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/NetworkException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.network.shared.exceptions + +class NetworkException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/ParsingException.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/ParsingException.kt new file mode 100644 index 0000000..0a6b9b3 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/ParsingException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.network.shared.exceptions + +class ParsingException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/network/src/test/java/org/fnives/test/showcase/network/auth/LoginErrorConverterTest.kt b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginErrorConverterTest.kt new file mode 100644 index 0000000..6dc87fd --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginErrorConverterTest.kt @@ -0,0 +1,71 @@ +package org.fnives.test.showcase.network.auth + +import com.squareup.moshi.JsonDataException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import okhttp3.internal.http.RealResponseBody +import okio.Buffer +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.model.LoginResponse +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Response +import java.io.IOException + +@Suppress("TestFunctionName") +class LoginErrorConverterTest { + + private lateinit var sut: LoginErrorConverter + + @BeforeEach + fun setUp() { + sut = LoginErrorConverter() + } + + @Test + fun GIVEN_throwing_lambda_WHEN_parsing_login_error_THEN_network_exception_is_thrown() { + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { + sut.invoke { throw IOException() } + } + } + } + + @Test + fun GIVEN_jsonException_throwing_lambda_WHEN_parsing_login_error_THEN_network_exception_is_thrown() { + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { + sut.invoke { throw JsonDataException("") } + } + } + } + + @Test + fun GIVEN_400_error_response_WHEN_parsing_login_error_THEN_invalid_credentials_is_returned() = runBlockingTest { + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.invoke { + val responseBody = RealResponseBody(null, 0, Buffer()) + Response.error(400, responseBody) + } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_successful_response_WHEN_parsing_login_error_THEN_successful_response_is_returned() = runBlockingTest { + 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/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceRefreshActionImplTest.kt b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceRefreshActionImplTest.kt new file mode 100644 index 0000000..fc71d92 --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceRefreshActionImplTest.kt @@ -0,0 +1,105 @@ +package org.fnives.test.showcase.network.auth + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject +import org.mockito.kotlin.mock + +@Suppress("TestFunctionName") +class LoginRemoteSourceRefreshActionImplTest : KoinTest { + + private val sut by inject() + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup + get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = { mock() }, + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun GIVEN_successful_response_WHEN_refresh_request_is_fired_THEN_session() = runBlocking { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success) + val expected = ContentData.refreshSuccessResponse + + val actual = sut.refresh(ContentData.refreshSuccessResponse.refreshToken) + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_successful_response_WHEN_refresh_request_is_fired_THEN_the_request_is_setup_properly() = runBlocking { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, 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()) + } + + @Test + fun GIVEN_internal_error_response_WHEN_refresh_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.refresh(ContentData.refreshSuccessResponse.refreshToken) } + } + } + + @Test + fun GIVEN_invalid_json_response_WHEN_refresh_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.UnexpectedJsonAsSuccessResponse) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) } + } + } + + @Test + fun GIVEN_malformed_json_response_WHEN_refresh_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.MalformedJson) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) } + } + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceTest.kt b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceTest.kt new file mode 100644 index 0000000..022e3ca --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceTest.kt @@ -0,0 +1,120 @@ +package org.fnives.test.showcase.network.auth + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.di.createNetworkModules +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.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject +import org.mockito.kotlin.mock +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode + +@Suppress("TestFunctionName") +class LoginRemoteSourceTest : KoinTest { + + private val sut by inject() + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup + get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = mock(), + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun GIVEN_successful_response_WHEN_request_is_fired_THEN_login_status_success_is_returned() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b")) + val expected = LoginStatusResponses.Success(ContentData.loginSuccessResponse) + + val actual = sut.login(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_successful_response_WHEN_request_is_fired_THEN_the_request_is_setup_properly() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"), false) + + sut.login(LoginCredentials("a", "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("a", "b") + JSONAssert.assertEquals(loginRequest, request.body.readUtf8(), JSONCompareMode.NON_EXTENSIBLE) + } + + @Test + fun GIVEN_bad_request_response_WHEN_request_is_fired_THEN_login_status_invalid_credentials_is_returned() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials("a", "b")) + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.login(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_internal_error_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(AuthScenario.GenericError("a", "b")) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } + } + } + + @Test + fun GIVEN_invalid_json_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(AuthScenario.UnexpectedJsonAsSuccessResponse("a", "b")) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } + } + } + + @Test + fun GIVEN_malformed_json_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(AuthScenario.MalformedJsonAsSuccessResponse("a", "b")) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } + } + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImplTest.kt b/network/src/test/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImplTest.kt new file mode 100644 index 0000000..f1996d2 --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImplTest.kt @@ -0,0 +1,124 @@ +package org.fnives.test.showcase.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +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 + +@Suppress("TestFunctionName") +class ContentRemoteSourceImplTest : KoinTest { + + private val sut: ContentRemoteSourceImpl by inject() + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + private val mockServerScenarioSetup + get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = { mock() }, + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun GIVEN_successful_response_WHEN_getting_content_THEN_its_parsed_and_returned_correctly() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Success(false)) + val expected = ContentData.contentSuccess + + val actual = sut.get() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_successful_response_WHEN_getting_content_THEN_the_request_is_setup_properly() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Success(false), 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()) + } + + @Test + fun GIVEN_response_with_missing_Field_WHEN_getting_content_THEN_invalid_is_ignored_others_are_returned() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.SuccessWithMissingFields(false)) + + val expected = ContentData.contentSuccessWithMissingFields + + val actual = sut.get() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_error_response_WHEN_getting_content_THEN_network_request_is_thrown() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Error(false)) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.get() } + } + } + + @Test + fun GIVEN_unexpected_json_response_WHEN_getting_content_THEN_parsing_request_is_thrown() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.UnexpectedJsonAsSuccessResponse(false)) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.get() } + } + } + + @Test + fun GIVEN_malformed_json_response_WHEN_getting_content_THEN_parsing_request_is_thrown() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.MalformedJsonAsSuccessResponse(false)) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.get() } + } + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/content/SessionExpirationTest.kt b/network/src/test/java/org/fnives/test/showcase/network/content/SessionExpirationTest.kt new file mode 100644 index 0000000..c7368f3 --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/content/SessionExpirationTest.kt @@ -0,0 +1,114 @@ +package org.fnives.test.showcase.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.di.createNetworkModules +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.fnives.test.showcase.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject +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.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +class SessionExpirationTest : KoinTest { + + private val sut: ContentRemoteSourceImpl by inject() + + @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() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = { mockNetworkSessionExpirationListener }, + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun GIVEN_401_THEN_refresh_token_ok_response_WHEN_content_requested_THE_tokens_are_refreshed_and_request_retried_with_new_tokens() = + runBlocking { + var sessionToReturnByMock: Session? = ContentData.loginSuccessResponse + mockServerScenarioSetup.setScenario( + ContentScenario.Unauthorized(false) + .then(ContentScenario.Success(true)), + false + ) + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, 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") + ) + verify(mockNetworkSessionLocalStorage, times(1)).session = ContentData.refreshSuccessResponse + verifyZeroInteractions(mockNetworkSessionExpirationListener) + } + + @Test + fun GIVEN_401_THEN_failing_refresh_WHEN_content_requested_THE_error_is_returned_and_callback_is_Called() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(false)) + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.get() } + } + verify(mockNetworkSessionLocalStorage, times(3)).session + verify(mockNetworkSessionLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockNetworkSessionLocalStorage) + verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired() + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/shared/MockServerScenarioSetupExtensions.kt b/network/src/test/java/org/fnives/test/showcase/network/shared/MockServerScenarioSetupExtensions.kt new file mode 100644 index 0000000..a74864d --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/shared/MockServerScenarioSetupExtensions.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.network.shared + +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 { + + val url: String = "${MockServerScenarioSetup.HTTP_BASE_URL}:${MockServerScenarioSetup.PORT}/" + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun beforeEach(context: ExtensionContext?) { + mockServerScenarioSetup = MockServerScenarioSetup() + mockServerScenarioSetup.start(false) + } + + override fun afterEach(context: ExtensionContext?) { + mockServerScenarioSetup.stop() + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..a7f91d5 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,6 @@ +include ':mockserver' +include ':model' +include ':core' +include ':network' +include ':app' +rootProject.name = "TestShowCase" \ No newline at end of file