diff --git a/core/build.gradle b/core/build.gradle index 3ca195f..9a43f9a 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -27,5 +27,7 @@ dependencies { testImplementation "com.squareup.retrofit2:retrofit:$retrofit_version" testImplementation "app.cash.turbine:turbine:$turbine_version" + testImplementation "org.junit.jupiter:junit-jupiter-params:$testing_junit5_version" + testImplementation project(':mockserver') } \ No newline at end of file diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/AuthIntegrationTest.kt b/core/src/test/java/org/fnives/test/showcase/core/integration/AuthIntegrationTest.kt new file mode 100644 index 0000000..41a2b77 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/integration/AuthIntegrationTest.kt @@ -0,0 +1,167 @@ +package org.fnives.test.showcase.core.integration + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.core.di.koin.createCoreModule +import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage +import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage +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.SessionExpirationListener +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +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.mockito.kotlin.verifyZeroInteractions +import java.util.stream.Stream + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthIntegrationTest : KoinTest { + + private lateinit var mockServerScenarioSetup: MockServerScenarioSetup + private lateinit var fakeFavouriteContentLocalStorage: FakeFavouriteContentLocalStorage + private lateinit var mockSessionExpirationListener: SessionExpirationListener + private lateinit var fakeUserDataLocalStorage: FakeUserDataLocalStorage + private val isUserLoggedInUseCase by inject() + private val loginUseCase by inject() + private val logoutUseCase by inject() + + @BeforeEach + fun setup() { + mockSessionExpirationListener = mock() + mockServerScenarioSetup = MockServerScenarioSetup() + val url = mockServerScenarioSetup.start(false) + fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage() + fakeUserDataLocalStorage = FakeUserDataLocalStorage(null) + + startKoin { + modules( + createCoreModule( + baseUrl = BaseUrl(url), + enableNetworkLogging = true, + favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage }, + sessionExpirationListenerProvider = { mockSessionExpirationListener }, + userDataLocalStorageProvider = { fakeUserDataLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + mockServerScenarioSetup.stop() + stopKoin() + } + + @DisplayName("GIVEN no session saved WHEN checking if user is logged in THEN they are not") + @Test + fun withoutSessionTheUserIsNotLoggedIn() = runTest { + fakeUserDataLocalStorage.session = null + val actual = isUserLoggedInUseCase.invoke() + + Assertions.assertFalse(actual, "User is expected to be not logged in") + verifyZeroInteractions(mockSessionExpirationListener) + } + + @DisplayName("GIVEN no session WHEN user is logging in THEN they get session") + @Test + fun login() = runTest { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true) + val expectedSession = ContentData.loginSuccessResponse + + val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc")) + val actual = isUserLoggedInUseCase.invoke() + + Assertions.assertEquals(Answer.Success(LoginStatus.SUCCESS), answer) + Assertions.assertTrue(actual, "User is expected to be logged in") + Assertions.assertEquals(expectedSession, fakeUserDataLocalStorage.session) + verifyZeroInteractions(mockSessionExpirationListener) + } + + @MethodSource("localInputErrorArguments") + @ParameterizedTest(name = "GIVEN {0} credentials WHEN login called THEN error {1} is shown") + fun localInputError(credentials: LoginCredentials, loginError: LoginStatus) = runTest { + val answer = loginUseCase.invoke(credentials) + val actual = isUserLoggedInUseCase.invoke() + + Assertions.assertEquals(Answer.Success(loginError), answer) + Assertions.assertFalse(actual, "User is expected to be not logged in") + Assertions.assertEquals(null, fakeUserDataLocalStorage.session) + verifyZeroInteractions(mockSessionExpirationListener) + } + + @DisplayName("GIVEN no session WHEN user is logging in THEN they get session") + @Test + fun loginInvalidCredentials() = runTest { + mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials(username = "usr", password = "sEc"), validateArguments = true) + + val answer = loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc")) + val actual = isUserLoggedInUseCase.invoke() + + Assertions.assertEquals(Answer.Success(LoginStatus.INVALID_CREDENTIALS), answer) + Assertions.assertFalse(actual, "User is expected to be not logged in") + Assertions.assertEquals(null, fakeUserDataLocalStorage.session) + verifyZeroInteractions(mockSessionExpirationListener) + } + + @MethodSource("networkErrorArguments") + @ParameterizedTest(name = "GIVEN {0} network response WHEN login called THEN error is shown") + fun networkInputError(authScenario: AuthScenario) = runTest { + mockServerScenarioSetup.setScenario(authScenario, validateArguments = true) + val credentials = LoginCredentials(username = authScenario.username, password = authScenario.password) + val answer = loginUseCase.invoke(credentials) + val actual = isUserLoggedInUseCase.invoke() + + Assertions.assertTrue(answer is Answer.Error, "Answer is expected to be an Error") + Assertions.assertFalse(actual, "User is expected to be not logged in") + Assertions.assertEquals(null, fakeUserDataLocalStorage.session) + verifyZeroInteractions(mockSessionExpirationListener) + } + + @DisplayName("GIVEN logged in user WHEN user is login out THEN they no longer have a session and content is cleared") + @Test + fun logout() = runTest { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "usr", password = "sEc"), validateArguments = true) + loginUseCase.invoke(LoginCredentials(username = "usr", password = "sEc")) + + logoutUseCase.invoke() + val actual = isUserLoggedInUseCase.invoke() + + Assertions.assertEquals(false, actual, "User is expected to be logged out") + verifyZeroInteractions(mockSessionExpirationListener) + } + + companion object { + + @JvmStatic + fun localInputErrorArguments() = Stream.of( + Arguments.of(LoginCredentials("", "password"), LoginStatus.INVALID_USERNAME), + Arguments.of(LoginCredentials("username", ""), LoginStatus.INVALID_PASSWORD) + ) + + @JvmStatic + fun networkErrorArguments() = Stream.of( + Arguments.of(AuthScenario.GenericError(username = "a", password = "b")), + Arguments.of(AuthScenario.UnexpectedJsonAsSuccessResponse(username = "a", password = "b")), + Arguments.of(AuthScenario.MalformedJsonAsSuccessResponse(username = "a", password = "b")), + Arguments.of(AuthScenario.MissingFieldJson(username = "a", password = "b")) + ) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt b/core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt index 00c280d..adc9e15 100644 --- a/core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt +++ b/core/src/test/java/org/fnives/test/showcase/core/integration/ContentIntegrationTest.kt @@ -37,9 +37,6 @@ import org.koin.core.context.stopKoin import org.koin.test.KoinTest import org.koin.test.inject 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 @OptIn(ExperimentalCoroutinesApi::class) @@ -335,30 +332,4 @@ class ContentIntegrationTest : KoinTest { val expectedSession = Session(accessToken = "refreshed-access", refreshToken = "refreshed-refresh") Assertions.assertEquals(expectedSession, fakeUserDataLocalStorage.session) } - - @DisplayName("GIVEN session expiration and failing token-refresh response WHEN observing THEN session expiration is attached") - @Test - fun sessionExpiration() = runTest { - mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error) - .setScenario( - ContentScenario.Unauthorized(usingRefreshedToken = false) - .then(ContentScenario.Success(usingRefreshedToken = true)) - ) - - val actual = async { - getAllContentUseCase.get() - .take(2) - .toList() - } - - val actualValues = actual.await() - Assertions.assertEquals(Resource.Loading>(), actualValues[0]) - Assertions.assertTrue(actualValues[1] is Resource.Error, "Resource is Error") - Assertions.assertTrue((actualValues[1] as Resource.Error).error is NetworkException, "Resource is Network Error") - - verify(mockSessionExpirationListener, times(1)).onSessionExpired() - verifyNoMoreInteractions(mockSessionExpirationListener) - - Assertions.assertEquals(null, fakeUserDataLocalStorage.session) - } } diff --git a/core/src/test/java/org/fnives/test/showcase/core/integration/SessionExpirationIntegrationTest.kt b/core/src/test/java/org/fnives/test/showcase/core/integration/SessionExpirationIntegrationTest.kt new file mode 100644 index 0000000..6417136 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/integration/SessionExpirationIntegrationTest.kt @@ -0,0 +1,117 @@ +package org.fnives.test.showcase.core.integration + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.di.koin.createCoreModule +import org.fnives.test.showcase.core.integration.fake.FakeFavouriteContentLocalStorage +import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.core.login.LoginUseCase +import org.fnives.test.showcase.core.session.SessionExpirationListener +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +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 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.DisplayName +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.koin.test.inject +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionExpirationIntegrationTest : KoinTest { + + private lateinit var mockServerScenarioSetup: MockServerScenarioSetup + private lateinit var fakeFavouriteContentLocalStorage: FakeFavouriteContentLocalStorage + private lateinit var mockSessionExpirationListener: SessionExpirationListener + private lateinit var fakeUserDataLocalStorage: FakeUserDataLocalStorage + private val isUserLoggedInUseCase by inject() + private val getAllContentUseCase by inject() + private val loginUseCase by inject() + private val fetchContentUseCase by inject() + + @BeforeEach + fun setup() { + mockSessionExpirationListener = mock() + mockServerScenarioSetup = MockServerScenarioSetup() + val url = mockServerScenarioSetup.start(false) + fakeFavouriteContentLocalStorage = FakeFavouriteContentLocalStorage() + fakeUserDataLocalStorage = FakeUserDataLocalStorage(null) + + startKoin { + modules( + createCoreModule( + baseUrl = BaseUrl(url), + enableNetworkLogging = true, + favouriteContentLocalStorageProvider = { fakeFavouriteContentLocalStorage }, + sessionExpirationListenerProvider = { mockSessionExpirationListener }, + userDataLocalStorageProvider = { fakeUserDataLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + mockServerScenarioSetup.stop() + stopKoin() + } + + @DisplayName("GIVEN logged in user WHEN fetching but expired THEN user is logged out") + @Test + fun sessionResultsInErrorAndClearsContent() = runTest { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = true) + loginUseCase.invoke(LoginCredentials(username = "a", password = "b")) + Assertions.assertTrue(isUserLoggedInUseCase.invoke()) + verifyZeroInteractions(mockSessionExpirationListener) + + mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false)) + .setScenario(RefreshTokenScenario.Error) + + getAllContentUseCase.get().take(2).toList() // getting session expiration + + verify(mockSessionExpirationListener, times(1)).onSessionExpired() + verifyNoMoreInteractions(mockSessionExpirationListener) + Assertions.assertFalse(isUserLoggedInUseCase.invoke(), "User is expected to be logged out") + } + + @DisplayName("GIVEN session expiration and failing token-refresh response WHEN requiring data THEN error is returned and data is cleared") + @Test + fun sessionExpirationResultsInLogout() = runTest { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "", password = ""), validateArguments = true) + loginUseCase.invoke(LoginCredentials(username = "", password = "")) + + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error) + .setScenario( + ContentScenario.Success(usingRefreshedToken = true) + .then(ContentScenario.Unauthorized(usingRefreshedToken = false)) + .then(ContentScenario.Success(usingRefreshedToken = true)) + ) + + getAllContentUseCase.get().take(2).toList() // cachedData + + fetchContentUseCase.invoke() + val unauthorizedData = getAllContentUseCase.get().take(2).last() + + Assertions.assertTrue(unauthorizedData is Resource.Error, "Resource is Error") + Assertions.assertTrue((unauthorizedData as Resource.Error).error is NetworkException, "Resource is Network Error") + } +}