From 392ebc51150e6fbb9d3127aa5602e1b5853ae70a Mon Sep 17 00:00:00 2001 From: Gergely Hegedus Date: Thu, 27 May 2021 12:25:46 +0300 Subject: [PATCH] network description --- codekata/networking.instructionset | 292 +++++++++++++++++- .../auth/CodeKataLoginRemoteSourceTest.kt | 91 ++++++ .../network/auth/LoginRemoteSourceTest.kt | 26 +- .../content/CodeKataSessionExpirationTest.kt | 61 ++++ .../network/content/SessionExpirationTest.kt | 12 +- .../resources/success_response_login.json | 4 + 6 files changed, 466 insertions(+), 20 deletions(-) create mode 100644 network/src/test/java/org/fnives/test/showcase/network/auth/CodeKataLoginRemoteSourceTest.kt create mode 100644 network/src/test/java/org/fnives/test/showcase/network/content/CodeKataSessionExpirationTest.kt create mode 100644 network/src/test/resources/success_response_login.json diff --git a/codekata/networking.instructionset b/codekata/networking.instructionset index 30404ce..5dba5b8 100644 --- a/codekata/networking.instructionset +++ b/codekata/networking.instructionset @@ -1 +1,291 @@ -TODO \ No newline at end of file +# Starting of networking testing + +In this testing instruction set you will learn how to write simple tests with retrofit. + +Every system under test will be injected to ensure the +Every test will use a mocked response and verify the requests sent out. +We will also verify how oauth token refreshing can be tested. + +I would suggest to open this document in your browser, while working in Android Studio. + +## Simple network test + +Our system under test will be `org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl` But we will only test the login method. The refresh method is part of sesssion handling, that's why it is internal. + +So the login method sends out a retrofit request and parses a response for us, this is what we intend to test. + +Let's setup our testClass: CodeKataLoginRemoteSourceTest + +### Setup +First since we are using Koin as Service Locator, we should extend KoinTest, thus giving us an easier way to access koin. + +```kotlin +class CodeKataLoginRemoteSourceTest : KoinTest { + +} +``` + +Next we need to inject our system under test and setup koin. However we also need to tearDown our setup after every test: +```kotlin +private val sut by inject() +private lateinit var mockWebServer : MockWebServer + +@BeforeEach +fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + val mockNetworkSessionLocalStorage = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockWebServer.url("mockserver/").toString()), + enableLogging = true, + networkSessionExpirationListenerProvider = mock(), + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } +} + +@AfterEach +fun tearDown() { + mockWebServer.shutdown() + stopKoin() +} +``` + +We use mockwebserver's rul as baseUrl thus every retrofit request we will send out the mockwebserver will capture. + +We also need to stop mockwebserver after every test so it doesn't have state sharing between tests. + +Koin also needs to be stopped so our LoginRemoteSource is injected every time. + +With this setup we are ready to start testing + +### 1. `successResponseParsedProperly` + +Notice we are starting with `runBlocking` instead of `runBlockingTest`. That's because okhttp / retrofit can have still not finished coroutines which would fail our runBlockingTest. + +First we need to setup mockwebserver to respond to our request and the expected value: + +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(readResourceFile("success_response_login.json"))) +val expected = LoginStatusResponses.Success(Session(accessToken = "login-access", refreshToken = "login-refresh")) +``` + +As you can see we can set the responseCode of the request and a body. here we use a helper function which goes to the resources folder and reads the content of the file. +Usually you will need to create your own files out of the expected responses so please check where the file is located. + +Next we declared the expected value, the accessToken and refreshToken come from the ResponseBody (aka the file), this is where it should be parsed from. + +We then declare the action +```kotlin +val actual = sut.login(LoginCredentials("a", "b")) +``` + +And verification +```kotlin +Assertions.assertEquals(expected, actual) +``` + +Now running this tet you should see logs by OkHttpLoggingInterceptor and see what request was sent out and received. + +### 2. `requestProperlySetup` + +So far we verified how to parse a response, but what about the validity of the request. This is what we will test next: + +First we setup the mockwebserver: +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(readResourceFile("success_response_login.json"))) +``` + +Next we call the method, and get the requestData from mockwebserver: +```kotlin +val actual = sut.login(LoginCredentials("a", "b")) +val request = mockWebServer.takeRequest() +``` + +Now that we have the requestData we can do verifications on it. +In this case we need to verify the method, headers, path, requestBody: + +```kotlin +Assertions.assertEquals("POST", request.method) +Assertions.assertEquals("Android", request.getHeader("Platform")) +Assertions.assertEquals(null, request.getHeader("Authorization")) +Assertions.assertEquals("/mockserver/login", request.path) +val loginRequest = """ +{ + "username": "a", + "password": "b" +} + """.trimIndent() +JSONAssert.assertEquals(loginRequest, request.body.readUtf8(), JSONCompareMode.NON_EXTENSIBLE) // Since the responseBody is json we use a library that can compare json properly +``` + +With this we can be sure our request contains exactly what we want it to contain. + +### 3. `badRequestMeansInvalidCredentials` + +Now we take a look at an expected error test: + +First we setup or mockwebserver to return 400. This should mean our credentials were invalid. + +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(400).setBody("")) +val expected = LoginStatusResponses.InvalidCredentials +``` + +Next we get the actual value and verify it: + +```kotlin +val actual = sut.login(LoginCredentials("a", "b")) + +Assertions.assertEquals(expected, actual) +``` +Notice we expected this error so no exception is thronw. + +### 4. `genericErrorMeansNetworkError` + +Next let's see if we get an unexpected response code. In such case we actually except a specific exception to be thrown, so it looks like this: + +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(500).setBody("")) + +Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } +} +``` + +### 5. `invalidJsonMeansParsingException` + +We also need to verify if we get an unexpected json, we handle that case properly: + +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + +Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } +} +``` + +### 6. `malformedJsonMeansParsingException` + +We can also get malformed json from the Backend, we still shouldn't crash: +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("{")) + +Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } +} +``` + +### Conclusion + +With these types of responses we should be covered for the generic errors that could happen, but additional tests needs to be adde for every specific HTTP code the client should handle. + +## Token Refreshing test + +In order to verify ouath token refreshing we need to have a remote source that is session dependent, for this we will use `org.fnives.test.showcase.network.content.ContentRemoteSource` + +So what happens with session expiration and token refreshing: +- a request is called that needs authentication +- the server responds with 401 meaning the token is expired +- we need to call refresh token request +- we get a new token and call the request again +- or we get an error and we propagate the error and send a sessionExpiration notice + +With that in mind open `CodeKataSessionExpirationTest` + +The setup is alreay done since it's equivalent to our LoginRemoteSource tests. + +### 1. `successRefreshResultsInRequestRetry` + +First we need to setup our mockwebserver with the expected requests: +- 401 content request +- success refresh token response +- success content request with the new token + +Also we need to update our `mockNetworkSessionLocalStorage` so it returns a beforeSession and later returns any session that was saved into it. + +So together: +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(401)) +mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(readResourceFile("success_response_login.json"))) +mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + +var sessionToReturnByMock: Session? = Session(accessToken = "before-access", refreshToken = "before-refresh") // before session +whenever(mockNetworkSessionLocalStorage.session).doAnswer { sessionToReturnByMock } // whenever requested return the value of sessionToReturnByMock +doAnswer { sessionToReturnByMock = it.arguments[0] as Session? } + .whenever(mockNetworkSessionLocalStorage).session = anyOrNull() // whenever set is called get the argument and save it into the sessionToReturnByMock +``` + +Now we need to take the action and get the requests to verify: +```kotlin +sut.get() +mockWebServer.takeRequest() +val refreshRequest = mockWebServer.takeRequest() +val retryAfterTokenRefreshRequest = mockWebServer.takeRequest() +``` + +Next we need to verify +- the refresh request was proper +- the new content request used the updated access token +- no session expiration event was sent and token was saved + +```kotlin +Assertions.assertEquals("PUT", refreshRequest.method) +Assertions.assertEquals("/mockserver/login/before-refresh", refreshRequest.path) +Assertions.assertEquals(null, refreshRequest.getHeader("Authorization")) +Assertions.assertEquals("Android", refreshRequest.getHeader("Platform")) +Assertions.assertEquals("", refreshRequest.body.readUtf8()) + +Assertions.assertEquals("login-access", retryAfterTokenRefreshRequest.getHeader("Authorization")) + +verify(mockNetworkSessionLocalStorage, times(1)).session = Session("login-access", "login-refresh") +verifyZeroInteractions(mockNetworkSessionExpirationListener) +``` + +### 2. `failingRefreshResultsInSessionExpiration` + +Now we need to test what if the refresh request fails. + +First setup for failuire: +```kotlin +mockWebServer.enqueue(MockResponse().setResponseCode(401)) +mockWebServer.enqueue(MockResponse().setResponseCode(400)) +whenever(mockNetworkSessionLocalStorage.session).doReturn(Session(accessToken = "before-access", refreshToken = "before-refresh")) +``` + +Next verify do the action which will throw! + +```kotlin +Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.get() } +} +``` + +Lastly verify the session was cleared and session expiration notified: + +```kotlin +verify(mockNetworkSessionLocalStorage, times(3)).session +verify(mockNetworkSessionLocalStorage, times(1)).session = null +verifyNoMoreInteractions(mockNetworkSessionLocalStorage) +verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired() +``` + +### Conclusion + +With these presented tests we can verify: +- our requests send the proper data +- our parsing is proper +- our error handling handles edge cases as well + +### Reusability + +Now if you wondered around the non-CodeKata Test files, you noticed it uses the mockserver module. + +The basic idea with this is that it contains all the response, request jsons and make it easier to setup sepcific scenarios. + +This wouldn't make sense if we are only testing networking, however if we want to write instrumentation / feature tests as well, there we also need to mock out the requests. + +So this can be a way to do this, but that should be up to you how to handle it. \ No newline at end of file diff --git a/network/src/test/java/org/fnives/test/showcase/network/auth/CodeKataLoginRemoteSourceTest.kt b/network/src/test/java/org/fnives/test/showcase/network/auth/CodeKataLoginRemoteSourceTest.kt new file mode 100644 index 0000000..c26664d --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/auth/CodeKataLoginRemoteSourceTest.kt @@ -0,0 +1,91 @@ +package org.fnives.test.showcase.network.auth + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import org.junit.jupiter.api.* +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 +import java.io.BufferedReader +import java.io.InputStreamReader + +class CodeKataLoginRemoteSourceTest { + + @BeforeEach + fun setUp() { + + } + + @AfterEach + fun tearDown() { + + } + + @DisplayName("GIVEN successful response WHEN request is fired THEN login status success is returned") + @Test + fun successResponseIsParsedProperly() = runBlocking { + + } + + @DisplayName("GIVEN successful response WHEN request is fired THEN the request is setup properly") + @Test + fun requestProperlySetup() = runBlocking { + + } + + @DisplayName("GIVEN bad request response WHEN request is fired THEN login status invalid credentials is returned") + @Test + fun badRequestMeansInvalidCredentials() = runBlocking { + + } + + @DisplayName("GIVEN_internal_error_response_WHEN_request_is_fired_THEN_network_exception_is_thrown") + @Test + fun genericErrorMeansNetworkError() { + + } + + @DisplayName("GIVEN invalid json response WHEN request is fired THEN network exception is thrown") + @Test + fun invalidJsonMeansParsingException() { + + } + + @DisplayName("GIVEN malformed json response WHEN request is fired THEN network exception is thrown") + @Test + fun malformedJsonMeansParsingException() { + + } + + companion object { + 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) + } + } + + } +} \ No newline at end of file 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 index 022e3ca..0258bf0 100644 --- 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 @@ -12,10 +12,7 @@ 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.* import org.junit.jupiter.api.extension.RegisterExtension import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -34,11 +31,10 @@ class LoginRemoteSourceTest : KoinTest { val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup - private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage @BeforeEach fun setUp() { - mockNetworkSessionLocalStorage = mock() + val mockNetworkSessionLocalStorage = mock() startKoin { modules( createNetworkModules( @@ -56,8 +52,9 @@ class LoginRemoteSourceTest : KoinTest { stopKoin() } + @DisplayName("GIVEN successful response WHEN request is fired THEN login status success is returned") @Test - fun GIVEN_successful_response_WHEN_request_is_fired_THEN_login_status_success_is_returned() = runBlocking { + fun successResponseIsParsedProperly() = runBlocking { mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b")) val expected = LoginStatusResponses.Success(ContentData.loginSuccessResponse) @@ -66,8 +63,9 @@ class LoginRemoteSourceTest : KoinTest { Assertions.assertEquals(expected, actual) } + @DisplayName("GIVEN successful response WHEN request is fired THEN the request is setup properly") @Test - fun GIVEN_successful_response_WHEN_request_is_fired_THEN_the_request_is_setup_properly() = runBlocking { + fun requestProperlySetup() = runBlocking { mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"), false) sut.login(LoginCredentials("a", "b")) @@ -81,8 +79,9 @@ class LoginRemoteSourceTest : KoinTest { JSONAssert.assertEquals(loginRequest, request.body.readUtf8(), JSONCompareMode.NON_EXTENSIBLE) } + @DisplayName("GIVEN bad request response WHEN request is fired THEN login status invalid credentials is returned") @Test - fun GIVEN_bad_request_response_WHEN_request_is_fired_THEN_login_status_invalid_credentials_is_returned() = runBlocking { + fun badRequestMeansInvalidCredentials() = runBlocking { mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials("a", "b")) val expected = LoginStatusResponses.InvalidCredentials @@ -91,8 +90,9 @@ class LoginRemoteSourceTest : KoinTest { Assertions.assertEquals(expected, actual) } + @DisplayName("GIVEN_internal_error_response_WHEN_request_is_fired_THEN_network_exception_is_thrown") @Test - fun GIVEN_internal_error_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + fun genericErrorMeansNetworkError() { mockServerScenarioSetup.setScenario(AuthScenario.GenericError("a", "b")) Assertions.assertThrows(NetworkException::class.java) { @@ -100,8 +100,9 @@ class LoginRemoteSourceTest : KoinTest { } } + @DisplayName("GIVEN invalid json response WHEN request is fired THEN network exception is thrown") @Test - fun GIVEN_invalid_json_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + fun invalidJsonMeansParsingException() { mockServerScenarioSetup.setScenario(AuthScenario.UnexpectedJsonAsSuccessResponse("a", "b")) Assertions.assertThrows(ParsingException::class.java) { @@ -109,8 +110,9 @@ class LoginRemoteSourceTest : KoinTest { } } + @DisplayName("GIVEN malformed json response WHEN request is fired THEN network exception is thrown") @Test - fun GIVEN_malformed_json_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + fun malformedJsonMeansParsingException() { mockServerScenarioSetup.setScenario(AuthScenario.MalformedJsonAsSuccessResponse("a", "b")) Assertions.assertThrows(ParsingException::class.java) { diff --git a/network/src/test/java/org/fnives/test/showcase/network/content/CodeKataSessionExpirationTest.kt b/network/src/test/java/org/fnives/test/showcase/network/content/CodeKataSessionExpirationTest.kt new file mode 100644 index 0000000..93d3408 --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/content/CodeKataSessionExpirationTest.kt @@ -0,0 +1,61 @@ +package org.fnives.test.showcase.network.content + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.CodeKataLoginRemoteSourceTest.Companion.readResourceFile +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.junit.jupiter.api.* +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.* + +class CodeKataSessionExpirationTest : KoinTest { + + private val sut by inject() + private lateinit var mockWebServer: MockWebServer + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + private lateinit var mockNetworkSessionExpirationListener: NetworkSessionExpirationListener + + @BeforeEach + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + mockNetworkSessionLocalStorage = mock() + mockNetworkSessionExpirationListener = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockWebServer.url("mockserver/").toString()), + enableLogging = true, + networkSessionExpirationListenerProvider = { mockNetworkSessionExpirationListener }, + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + mockWebServer.shutdown() + } + + @DisplayName("GIVEN_401_THEN_refresh_token_ok_response_WHEN_content_requested_THE_tokens_are_refreshed_and_request_retried_with_new_tokens") + @Test + fun successRefreshResultsInRequestRetry() = runBlocking { + } + + @DisplayName("GIVEN 401 THEN failing refresh WHEN content requested THE error is returned and callback is Called") + @Test + fun failingRefreshResultsInSessionExpiration() = runBlocking { + + } +} \ No newline at end of file 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 index c7368f3..e63924d 100644 --- 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 @@ -11,10 +11,7 @@ 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.* import org.junit.jupiter.api.extension.RegisterExtension import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -64,9 +61,9 @@ class SessionExpirationTest : KoinTest { stopKoin() } + @DisplayName("GIVEN_401_THEN_refresh_token_ok_response_WHEN_content_requested_THE_tokens_are_refreshed_and_request_retried_with_new_tokens") @Test - fun GIVEN_401_THEN_refresh_token_ok_response_WHEN_content_requested_THE_tokens_are_refreshed_and_request_retried_with_new_tokens() = - runBlocking { + fun successRefreshResultsInRequestRetry() = runBlocking { var sessionToReturnByMock: Session? = ContentData.loginSuccessResponse mockServerScenarioSetup.setScenario( ContentScenario.Unauthorized(false) @@ -97,8 +94,9 @@ class SessionExpirationTest : KoinTest { verifyZeroInteractions(mockNetworkSessionExpirationListener) } + @DisplayName("GIVEN 401 THEN failing refresh WHEN content requested THE error is returned and callback is Called") @Test - fun GIVEN_401_THEN_failing_refresh_WHEN_content_requested_THE_error_is_returned_and_callback_is_Called() = runBlocking { + fun failingRefreshResultsInSessionExpiration() = runBlocking { whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(false)) mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error) diff --git a/network/src/test/resources/success_response_login.json b/network/src/test/resources/success_response_login.json new file mode 100644 index 0000000..ba930ef --- /dev/null +++ b/network/src/test/resources/success_response_login.json @@ -0,0 +1,4 @@ +{ + "accessToken": "login-access", + "refreshToken": "login-refresh" +} \ No newline at end of file