From 7a3996d77ee86b4ae67386e5927eeb7c9e8c6cff Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 09:49:12 +0900 Subject: [PATCH 1/3] Characterize the repositories through the real network stack RepositoryRequestsTest runs every repository through NetModule's OkHttp client and Retrofit converters (with AuthInterceptor and AcceptLanguageInterceptor) against a local HTTP server, pinning each call's method, path, query and body, the session and Accept-Language headers, and how error bodies become ApiExceptions. ApiResponseExtensionsTest adds devise's full_messages body, JSON and empty bodies without a message, and the body code vs the HTTP status. AuthInterceptorTest adds the case-insensitive API host match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../data/RepositoryRequestsTest.kt | 397 ++++++++++++++++++ .../network/ApiResponseExtensionsTest.kt | 55 +++ .../network/AuthInterceptorTest.kt | 14 + 3 files changed, 466 insertions(+) create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt new file mode 100644 index 0000000..01be473 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt @@ -0,0 +1,397 @@ +package com.nativeapptemplate.nativeapptemplatefree.data + +import com.nativeapptemplate.nativeapptemplatefree.UserPreferences +import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagApi +import com.nativeapptemplate.nativeapptemplatefree.data.item_tag.ItemTagRepositoryImpl +import com.nativeapptemplate.nativeapptemplatefree.data.login.AccountPasswordApi +import com.nativeapptemplate.nativeapptemplatefree.data.login.AccountPasswordRepositoryImpl +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginApi +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepositoryImpl +import com.nativeapptemplate.nativeapptemplatefree.data.login.SignUpApi +import com.nativeapptemplate.nativeapptemplatefree.data.login.SignUpRepositoryImpl +import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopApi +import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepositoryImpl +import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePreferencesDataSource +import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore +import com.nativeapptemplate.nativeapptemplatefree.di.modules.NetModule +import com.nativeapptemplate.nativeapptemplatefree.di.modules.sessionRedactingLoggingInterceptor +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBody +import com.nativeapptemplate.nativeapptemplatefree.model.ItemTagBodyDetail +import com.nativeapptemplate.nativeapptemplatefree.model.SendConfirmation +import com.nativeapptemplate.nativeapptemplatefree.model.SendResetPassword +import com.nativeapptemplate.nativeapptemplatefree.model.ShopBody +import com.nativeapptemplate.nativeapptemplatefree.model.ShopBodyDetail +import com.nativeapptemplate.nativeapptemplatefree.model.ShopUpdateBody +import com.nativeapptemplate.nativeapptemplatefree.model.ShopUpdateBodyDetail +import com.nativeapptemplate.nativeapptemplatefree.model.SignUp +import com.nativeapptemplate.nativeapptemplatefree.model.SignUpForUpdate +import com.nativeapptemplate.nativeapptemplatefree.model.UpdatePasswordBody +import com.nativeapptemplate.nativeapptemplatefree.model.UpdatePasswordBodyDetail +import com.nativeapptemplate.nativeapptemplatefree.network.AcceptLanguageInterceptor +import com.nativeapptemplate.nativeapptemplatefree.network.AuthInterceptor +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.logging.HttpLoggingInterceptor +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Retrofit +import java.net.InetAddress +import java.net.InetSocketAddress +import kotlin.test.assertFailsWith + +/** + * The repositories through the app's real network stack (NetModule's OkHttp client and Retrofit + * converters, AuthInterceptor, AcceptLanguageInterceptor) against a local HTTP server, so the + * requests the server receives and how its answers come back are pinned down before refactoring. + * + * Paths and methods come from the API routes the Retrofit interfaces declare (e.g. ItemTagApi: + * `GET {account_id}/api/v1/shopkeeper/shops/{shop_id}/item_tags`); the account id is the one saved + * at sign-in (UserPreferences.accountId). + */ +class RepositoryRequestsTest { + private class Received(val method: String, val pathAndQuery: String, val headers: Map, val body: String) + + private val received = mutableListOf() + private var status = 200 + private var responseBody = "{}" + + private val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0).apply { + createContext("/") { exchange -> + val uri = exchange.requestURI + received += Received( + method = exchange.requestMethod, + pathAndQuery = uri.rawPath + (uri.rawQuery?.let { "?$it" } ?: ""), + headers = exchange.requestHeaders.mapKeys { it.key.lowercase() }.mapValues { it.value.firstOrNull() }, + body = exchange.requestBody.readBytes().decodeToString(), + ) + val bytes = responseBody.encodeToByteArray() + exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8") + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + start() + } + + @After + fun tearDown() { + server.stop(0) + } + + private val dataSource = NativeAppTemplatePreferencesDataSource( + InMemoryDataStore( + UserPreferences.newBuilder() + .setAccountId(ACCOUNT_ID) + .setToken("test-token") + .setClient("test-client") + .setUid("john@example.com") + .setExpiry("1767225600") + .setIsLoggedIn(true) + .build(), + ), + ) + + private val retrofit: Retrofit = NetModule().let { module -> + val okHttp = module.provideOkHttp( + loggingInterceptor = sessionRedactingLoggingInterceptor(HttpLoggingInterceptor.Level.NONE), + // The API host as the client reaches this server. + authInterceptor = AuthInterceptor(dataSource, apiHost = "127.0.0.1"), + acceptLanguageInterceptor = AcceptLanguageInterceptor { listOf("ja-JP", "en-US") }, + ) + // NetModule's converter and call adapter, pointed at the local server. + module.provideRetrofit(okHttp).newBuilder() + .baseUrl("http://127.0.0.1:${server.address.port}/") + .build() + } + + private val itemTagRepository = ItemTagRepositoryImpl(dataSource, ItemTagApi.create(retrofit), Dispatchers.IO) + private val shopRepository = ShopRepositoryImpl(dataSource, ShopApi.create(retrofit), Dispatchers.IO) + private val loginRepository = LoginRepositoryImpl(LoginApi.create(retrofit), dataSource, Dispatchers.IO) + private val signUpRepository = SignUpRepositoryImpl(SignUpApi.create(retrofit), Dispatchers.IO) + private val accountPasswordRepository = AccountPasswordRepositoryImpl(dataSource, AccountPasswordApi.create(retrofit), Dispatchers.IO) + + private fun respond(status: Int, body: String) { + this.status = status + this.responseBody = body + } + + @Test + fun getItemTags_requestsThePageOfTheShopInTheSignedInAccount() = runBlocking { + respond(200, """{"data":[],"meta":{"current_page":2,"total_pages":3}}""") + + val itemTags = itemTagRepository.getItemTags(SHOP_ID, page = 2).first() + + assertEquals("GET", received.single().method) + assertEquals("/$ACCOUNT_ID/api/v1/shopkeeper/shops/$SHOP_ID/item_tags?page=2", received.single().pathAndQuery) + assertEquals(2, itemTags.meta?.currentPage) + assertEquals(3, itemTags.meta?.totalPages) + } + + @Test + fun getItemTags_withoutAPage_sendsNoPageParameter() = runBlocking { + respond(200, """{"data":[]}""") + + itemTagRepository.getItemTags(SHOP_ID, page = null).first() + + // Retrofit leaves out a @Query whose value is null. + assertEquals("/$ACCOUNT_ID/api/v1/shopkeeper/shops/$SHOP_ID/item_tags", received.single().pathAndQuery) + } + + @Test + fun everyApiRequest_carriesTheSessionAndTheDeviceLanguages() = runBlocking { + respond(200, """{"data":[]}""") + + itemTagRepository.getItemTags(SHOP_ID, page = null).first() + + val headers = received.single().headers + // RequestHelper.getHeaders() with the stored session. + assertEquals("test-token", headers["access-token"]) + assertEquals("test-client", headers["client"]) + assertEquals("john@example.com", headers["uid"]) + assertEquals("1767225600", headers["expiry"]) + assertEquals("android", headers["source"]) + // acceptLanguageHeader(["ja-JP", "en-US"]): the first tag, then q = 0.9. + assertEquals("ja-JP, en-US;q=0.9", headers["accept-language"]) + } + + @Test + fun updateItemTag_sendsAnEmptyDescription() = runBlocking { + respond(200, """{"data":{"id":"$ITEM_TAG_ID","type":"item_tag","attributes":{"name":"A001","description":""}}}""") + + itemTagRepository.updateItemTag(ITEM_TAG_ID, ItemTagBody(ItemTagBodyDetail(name = "A001", description = ""))).first() + + val request = received.single() + assertEquals("PATCH", request.method) + assertEquals("/$ACCOUNT_ID/api/v1/shopkeeper/item_tags/$ITEM_TAG_ID", request.pathAndQuery) + // ItemTagBodyDetail.description has no default, so "" is sent and the server clears it + // (the API updates only the attributes present). + assertEquals("""{"item_tag":{"name":"A001","description":""}}""", request.body) + } + + @Test + fun completeAndIdleItemTag_patchTheirStateRoutes() = runBlocking { + respond(200, """{"data":{"id":"$ITEM_TAG_ID","type":"item_tag","attributes":{"state":"completed"}}}""") + + val completed = itemTagRepository.completeItemTag(ITEM_TAG_ID).first() + itemTagRepository.idleItemTag(ITEM_TAG_ID).first() + + assertEquals(listOf("PATCH", "PATCH"), received.map { it.method }) + assertEquals( + listOf( + "/$ACCOUNT_ID/api/v1/shopkeeper/item_tags/$ITEM_TAG_ID/complete", + "/$ACCOUNT_ID/api/v1/shopkeeper/item_tags/$ITEM_TAG_ID/idle", + ), + received.map { it.pathAndQuery }, + ) + assertEquals("completed", completed.getState()) + } + + @Test + fun createShop_sendsANullDescriptionAsNull() = runBlocking { + respond(201, """{"data":{"id":"$SHOP_ID","type":"shop","attributes":{"name":"8th & Townsend"}}}""") + + val shop = shopRepository.createShop(ShopBody(ShopBodyDetail(name = "8th & Townsend", description = null, timeZone = "Tokyo"))).first() + + val request = received.single() + assertEquals("POST", request.method) + assertEquals("/$ACCOUNT_ID/api/v1/shopkeeper/shops", request.pathAndQuery) + // ShopBodyDetail.description has no default, so it is always written; kotlinx.serialization's + // explicitNulls (true unless the Json config turns it off) writes null as null. + assertEquals("""{"shop":{"name":"8th & Townsend","description":null,"time_zone":"Tokyo"}}""", request.body) + assertEquals("8th & Townsend", shop.getName()) + } + + @Test + fun deleteShop_emitsTrueForAnyStatusBody() = runBlocking { + respond(200, """{"status":200}""") + + val deleted = shopRepository.deleteShop(SHOP_ID).first() + + assertEquals("DELETE", received.single().method) + assertEquals("/$ACCOUNT_ID/api/v1/shopkeeper/shops/$SHOP_ID", received.single().pathAndQuery) + assertTrue(deleted) + } + + @Test + fun getPermissions_requestsTheSignedInAccountsPermissions() = runBlocking { + respond(200, """{"data":[],"meta":{"android_app_version":7,"should_update_privacy":true,"should_update_terms":false,"shop_limit_count":10}}""") + + val permissions = loginRepository.getPermissions().first() + + assertEquals("GET", received.single().method) + assertEquals("/$ACCOUNT_ID/api/v1/shopkeeper/permissions", received.single().pathAndQuery) + assertEquals(7, permissions.getAndroidAppVersion()) + assertEquals(true, permissions.getShouldUpdatePrivacy()) + assertEquals(10, permissions.getShopLimitCount()) + } + + @Test + fun confirmingThePrivacyAndTerms_patchesTheirRoutes() = runBlocking { + respond(200, """{"status":200}""") + + assertTrue(loginRepository.updateConfirmedPrivacyVersion().first()) + assertTrue(loginRepository.updateConfirmedTermsVersion().first()) + + assertEquals( + listOf( + "PATCH /$ACCOUNT_ID/api/v1/shopkeeper/me/update_confirmed_privacy_version", + "PATCH /$ACCOUNT_ID/api/v1/shopkeeper/me/update_confirmed_terms_version", + ), + received.map { "${it.method} ${it.pathAndQuery}" }, + ) + } + + @Test + fun logout_clearsTheStoredSession() = runBlocking { + respond(200, """{"success":true}""") + + loginRepository.logout().first() + + assertEquals("DELETE /shopkeeper_auth/sign_out", received.single().let { "${it.method} ${it.pathAndQuery}" }) + // clearUserPreferences resets UserPreferences to its defaults. + val userData = dataSource.userData.first() + assertFalse(userData.isLoggedIn) + assertEquals("", userData.token) + assertEquals("", userData.accountId) + } + + @Test + fun logout_whenTheServerFails_stillClearsTheStoredSession() = runBlocking { + respond(500, """{"errors":["Internal Server Error"]}""") + + assertFailsWith { loginRepository.logout().first() } + + assertFalse(dataSource.userData.first().isLoggedIn) + assertEquals("", dataSource.userData.first().token) + } + + @Test + fun updateLocale_patchesOnlyTheLocale() = runBlocking { + respond(200, """{"data":{"id":"shopkeeper-1","attributes":{"locale":"ja"}}}""") + + val shopkeeper = signUpRepository.updateLocale("ja").first() + + assertEquals("PATCH /shopkeeper_auth", received.single().let { "${it.method} ${it.pathAndQuery}" }) + assertEquals("""{"locale":"ja"}""", received.single().body) + assertEquals("ja", shopkeeper.getLocale()) + } + + @Test + fun updateAccountPassword_patchesThePasswordOfTheSignedInAccount() = runBlocking { + respond(200, """{"status":200}""") + + val updated = accountPasswordRepository.updateAccountPassword( + UpdatePasswordBody(UpdatePasswordBodyDetail(currentPassword = "old-password", password = "new-password", passwordConfirmation = "new-password")), + ).first() + + assertEquals("PATCH /$ACCOUNT_ID/api/v1/shopkeeper/account/password", received.single().let { "${it.method} ${it.pathAndQuery}" }) + assertEquals( + """{"shopkeeper":{"current_password":"old-password","password":"new-password","password_confirmation":"new-password"}}""", + received.single().body, + ) + assertTrue(updated) + } + + // The remaining calls' routes, so a refactoring of the Api interfaces or repositories cannot + // silently move one. Each answer is "{}", which every response model decodes (all fields optional). + @Test + fun eachOtherCall_usesItsRoute() = runBlocking { + respond(200, "{}") + val itemTagBody = ItemTagBody(ItemTagBodyDetail(name = "A001", description = "")) + val shopUpdateBody = ShopUpdateBody(ShopUpdateBodyDetail(name = "8th & Townsend", description = "", timeZone = "Tokyo")) + + shopRepository.getShops().first() + shopRepository.getShop(SHOP_ID).first() + shopRepository.updateShop(SHOP_ID, shopUpdateBody).first() + itemTagRepository.getItemTag(ITEM_TAG_ID).first() + itemTagRepository.createItemTag(SHOP_ID, itemTagBody).first() + itemTagRepository.deleteItemTag(ITEM_TAG_ID).first() + signUpRepository.updateAccount(SignUpForUpdate(name = "John", email = "john@example.com", timeZone = "Tokyo", locale = "en")).first() + signUpRepository.deleteAccount().first() + signUpRepository.sendResetPasswordInstruction(SendResetPassword(email = "john@example.com", redirectUrl = "https://example.com/r")).first() + signUpRepository.sendConfirmationInstruction(SendConfirmation(email = "john@example.com", redirectUrl = "https://example.com/c")).first() + + assertEquals( + listOf( + "GET /$ACCOUNT_ID/api/v1/shopkeeper/shops", + "GET /$ACCOUNT_ID/api/v1/shopkeeper/shops/$SHOP_ID", + "PATCH /$ACCOUNT_ID/api/v1/shopkeeper/shops/$SHOP_ID", + "GET /$ACCOUNT_ID/api/v1/shopkeeper/item_tags/$ITEM_TAG_ID", + "POST /$ACCOUNT_ID/api/v1/shopkeeper/shops/$SHOP_ID/item_tags", + "DELETE /$ACCOUNT_ID/api/v1/shopkeeper/item_tags/$ITEM_TAG_ID", + "PATCH /shopkeeper_auth", + "DELETE /shopkeeper_auth", + "POST /shopkeeper_auth/password", + "POST /shopkeeper_auth/confirmation", + ), + received.map { "${it.method} ${it.pathAndQuery}" }, + ) + } + + @Test + fun signUp_postsTheShopkeeperWithTheAppLanguage() = runBlocking { + respond(200, """{"data":{"id":"shopkeeper-1","attributes":{"email":"john@example.com"}}}""") + + signUpRepository.signUp( + SignUp(name = "John", email = "john@example.com", timeZone = "Tokyo", password = "password", currentPlatform = "android", locale = "ja"), + ).first() + + assertEquals("POST /shopkeeper_auth", received.single().let { "${it.method} ${it.pathAndQuery}" }) + // SignUp's properties in declaration order, with their @SerialName. + assertEquals( + """{"name":"John","email":"john@example.com","time_zone":"Tokyo","password":"password","current_platform":"android","locale":"ja"}""", + received.single().body, + ) + } + + @Test + fun aValidationError_becomesAnApiErrorWithTheServerMessage() = runBlocking { + // The API's error body: {"code": ..., "error_message": ...} (NativeAppTemplateApiError). + respond(422, """{"code":422,"error_message":"Name has already been taken"}""") + + val exception = assertFailsWith { + shopRepository.createShop(ShopBody(ShopBodyDetail(name = "Taken", description = "", timeZone = "Tokyo"))).first() + } + + assertEquals("Name has already been taken", exception.apiMessage) + assertEquals(422, exception.httpStatusCode) + // ApiException.ApiError: "[NATIVEAPPTEMPLATE-2001] " + "$apiMessage [Status: $code]". + assertEquals("[NATIVEAPPTEMPLATE-2001] Name has already been taken [Status: 422]", exception.formattedDescription) + } + + @Test + fun aRejectedSession_isUnauthorized() = runBlocking { + // devise_token_auth's answer to an expired or revoked token. + respond(401, """{"errors":["You need to sign in or sign up before continuing."]}""") + + val exception = assertFailsWith { shopRepository.getShops().first() } + + assertTrue(exception.isUnauthorized) + assertEquals("You need to sign in or sign up before continuing.", exception.rawMessage) + } + + @Test + fun aSignedOutRequest_carriesNoTokenAndNoAccountSegment() = runBlocking { + dataSource.clearUserPreferences() + respond(200, """{"data":[]}""") + + shopRepository.getShops().first() + + // RequestHelper adds the session headers only when there is a token. + assertNull(received.single().headers["access-token"]) + // Current behavior, not a contract: the empty account id turns "{account_id}/api/..." into + // "/api/...", which Retrofit resolves (RFC 3986, an absolute path) to the host root, so the + // request goes to a route without the account segment instead of failing locally. + assertEquals("/api/v1/shopkeeper/shops", received.single().pathAndQuery) + } +} + +private const val ACCOUNT_ID = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11" +private const val SHOP_ID = "5712F2DF-DFC7-A3AA-66BC-191203654A1A" +private const val ITEM_TAG_ID = "9712F2DF-DFC7-A3AA-66BC-191203654A1A" diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt index 37e178a..4ce4f02 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt @@ -143,4 +143,59 @@ class ApiResponseExtensionsTest { assertEquals("HTTP 502", exception.rawMessage) } + + @Test + fun emitApiResponse_deviseFullMessagesBody_showsTheFirstMessage() = runTest { + // devise_token_auth's registration errors: {"errors": {"": [...], "full_messages": [...]}}. + val response = httpError( + 422, + """{"status":"error","errors":{"email":["has already been taken"],"full_messages":["Email has already been taken","Password is too short"]}}""", + ) + + val exception = assertFailsWith { + flow { emitApiResponse(response) }.first() + } + + assertEquals("Email has already been taken", exception.rawMessage) + assertEquals(422, exception.httpStatusCode) + } + + @Test + fun emitApiResponse_jsonBodyWithoutErrors_showsTheHttpStatus() = runTest { + // Valid JSON, but neither {code, error_message} nor devise's "errors". + val response = httpError(500, """{"status":500,"error":"Internal Server Error"}""") + + val exception = assertFailsWith { + flow { emitApiResponse(response) }.first() + } + + assertEquals("HTTP 500", exception.rawMessage) + } + + @Test + fun emitApiResponse_emptyErrorBody_showsTheHttpStatus() = runTest { + val response = httpError(503, "") + + val exception = assertFailsWith { + flow { emitApiResponse(response) }.first() + } + + assertEquals("HTTP 503", exception.rawMessage) + assertEquals(503, exception.httpStatusCode) + } + + @Test + fun throwApiError_apiErrorBody_takesTheCodeFromTheBodyAndTheStatusFromTheResponse() { + // The two can differ; ApiError.code (shown as "[Status: code]") is the body's. + val response = httpError(403, """{"code":1001,"error_message":"Shop limit reached"}""") + + val exception = assertFailsWith { + throwApiError(response, "forbidden") + } + + assertEquals(1001, exception.code) + assertEquals(403, exception.httpStatusCode) + // ApiException.ApiError: "[NATIVEAPPTEMPLATE-2001] " + "$apiMessage [Status: $code]". + assertEquals("[NATIVEAPPTEMPLATE-2001] Shop limit reached [Status: 1001]", exception.formattedDescription) + } } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt index a51c88b..8b19cf5 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt @@ -175,6 +175,20 @@ class AuthInterceptorTest { assertNull(sent.header("access-token")) } + @Test + fun intercept_apiHostConfiguredInAnotherCase_isStillTheApiHost() = testScope.runTest { + // Host names are case-insensitive (RFC 4343), and OkHttp lowercases the request's host, so a + // NATIVEAPPTEMPLATE_API_DOMAIN written with capitals must still match. + val interceptor = AuthInterceptor( + dataSourceWith(token = "test-token", client = "test-client", uid = "john@example.com", expiry = "12345"), + apiHost = "Example.COM", + ) + + val sent = interceptor.sentRequest(Request.Builder().url("https://example.com/").build()) + + assertEquals("test-token", sent.header("access-token")) + } + @Test fun intercept_preservesOriginalRequestUrl() = testScope.runTest { val dataSource = NativeAppTemplatePreferencesDataSource( From 52302819f89c02bc9b24833a109c1fd5eff09397 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 09:49:26 +0900 Subject: [PATCH 2/3] Characterize the preferences, app state and Data.updateRelationships - NativeAppTemplatePreferencesDataSource: the stored session, the shouldUpdateApp derivation from BuildConfig.VERSION_CODE, dark mode, the flags, and clearUserPreferences resetting everything (including dark mode and the shop tip). - NativeAppTemplateAppState over the real preferences: authDestination (null until loaded, then following sign-in and the permissions), shouldShowBottomBar, isOffline and the deletion flags. - Data.updateRelationships and its callers: "included" is merged into nothing today; the results equal the decoded data. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- ...iveAppTemplatePreferencesDataSourceTest.kt | 86 ++++++++++ .../app_root/NativeAppTemplateAppStateTest.kt | 151 ++++++++++++++++++ .../model/DataRelationshipsTest.kt | 100 ++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt create mode 100644 model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt index 6c44e95..1f8eeb0 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt @@ -1,9 +1,11 @@ package com.nativeapptemplate.nativeapptemplatefree.datastore +import com.nativeapptemplate.nativeapptemplatefree.BuildConfig import com.nativeapptemplate.nativeapptemplatefree.UserPreferences import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore import com.nativeapptemplate.nativeapptemplatefree.di.modules.networkJson import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Meta @@ -154,6 +156,90 @@ class NativeAppTemplatePreferencesDataSourceTest { assertTrue(userData.isLoggedIn) assertEquals("", userData.accountName) } + + @Test + fun setShopkeeper_storesTheSessionFromTheSignInResponse() = testScope.runTest { + subject.setShopkeeper(testInputLoggedInShopkeeper) + + val userData = subject.userData.first() + assertEquals(LOGGED_IN_SHOPKEEPER_ID, userData.id) + assertEquals(LOGGED_IN_SHOPKEEPER_ACCOUNT_ID, userData.accountId) + assertEquals(LOGGED_IN_SHOPKEEPER_TOKEN, userData.token) + assertEquals(LOGGED_IN_SHOPKEEPER_CLIENT, userData.client) + assertEquals(LOGGED_IN_SHOPKEEPER_UID, userData.uid) + assertEquals(LOGGED_IN_SHOPKEEPER_EXPIRY, userData.expiry) + assertEquals(LOGGED_IN_SHOPKEEPER_TIME_ZONE, userData.timeZone) + } + + // shouldUpdateApp = this build's versionCode (BuildConfig.VERSION_CODE) is older than the + // android_app_version the server requires. + @Test + fun setPermissions_aNewerRequiredAppVersion_requiresAnAppUpdate() = testScope.runTest { + subject.setPermissions(Permissions(meta = Meta(androidAppVersion = BuildConfig.VERSION_CODE + 1))) + + assertTrue(subject.shouldUpdateApp().first()) + assertEquals(BuildConfig.VERSION_CODE + 1, subject.userData.first().androidAppVersion) + } + + @Test + fun setPermissions_theCurrentAppVersion_requiresNoAppUpdate() = testScope.runTest { + subject.setPermissions(Permissions(meta = Meta(androidAppVersion = BuildConfig.VERSION_CODE + 1))) + + // The required version is this build's own: no longer older. + subject.setPermissions(Permissions(meta = Meta(androidAppVersion = BuildConfig.VERSION_CODE))) + + assertFalse(subject.shouldUpdateApp().first()) + } + + @Test + fun darkThemeConfig_isFollowSystemUntilSet_thenWhatWasSet() = testScope.runTest { + // DARK_THEME_CONFIG_UNSPECIFIED (proto3's default 0) reads as FOLLOW_SYSTEM. + assertEquals(DarkThemeConfig.FOLLOW_SYSTEM, subject.userData.first().darkThemeConfig) + + DarkThemeConfig.entries.forEach { config -> + subject.setDarkThemeConfig(config) + assertEquals(config, subject.userData.first().darkThemeConfig) + } + } + + // Signing out (LoginRepositoryImpl.logout) clears the whole UserPreferences: the session, and also + // the device-level settings stored next to it (dark mode, the shop tip). Current behavior. + @Test + fun clearUserPreferences_resetsEverythingToTheDefaults() = testScope.runTest { + subject.setShopkeeper(testInputLoggedInShopkeeper) + subject.setPermissions(Permissions(meta = Meta(androidAppVersion = BuildConfig.VERSION_CODE + 1, shouldUpdatePrivacy = true, shouldUpdateTerms = true, shopLimitCount = 99))) + subject.setDarkThemeConfig(DarkThemeConfig.DARK) + subject.setDidShowTapShopBelowTip(true) + subject.setIsShopDeleted(true) + + subject.clearUserPreferences() + + val userData = subject.userData.first() + assertFalse(userData.isLoggedIn) + assertEquals("", userData.token) + assertEquals("", userData.accountId) + assertFalse(subject.shouldUpdateApp().first()) + assertFalse(subject.shouldUpdatePrivacy().first()) + assertEquals(DarkThemeConfig.FOLLOW_SYSTEM, userData.darkThemeConfig) + assertFalse(subject.didShowTapShopBelowTip().first()) + assertFalse(subject.isShopDeleted().first()) + } + + @Test + fun flags_readBackWhatWasSet() = testScope.runTest { + subject.setIsEmailUpdated(true) + subject.setIsMyAccountDeleted(true) + subject.setIsShopDeleted(true) + subject.setDidShowTapShopBelowTip(true) + + assertTrue(subject.isEmailUpdated().first()) + assertTrue(subject.isMyAccountDeleted().first()) + assertTrue(subject.isShopDeleted().first()) + assertTrue(subject.didShowTapShopBelowTip().first()) + + subject.setIsShopDeleted(false) + assertFalse(subject.isShopDeleted().first()) + } } private const val LOGGED_IN_SHOPKEEPER_TYPE = "shopkeeper_sign_in" diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt new file mode 100644 index 0000000..40e5273 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt @@ -0,0 +1,151 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import androidx.navigation.testing.TestNavHostController +import androidx.test.core.app.ApplicationProvider +import com.nativeapptemplate.nativeapptemplatefree.BuildConfig +import com.nativeapptemplate.nativeapptemplatefree.UserPreferences +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginApi +import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepositoryImpl +import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePreferencesDataSource +import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore +import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.Login +import com.nativeapptemplate.nativeapptemplatefree.model.Meta +import com.nativeapptemplate.nativeapptemplatefree.model.Permissions +import com.nativeapptemplate.nativeapptemplatefree.model.Status +import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.navigation.AuthDestination +import com.nativeapptemplate.nativeapptemplatefree.utils.NetworkMonitor +import com.skydoves.sandwich.ApiResponse +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The app-wide state the root composable routes and draws from, over the real preferences + * (LoginRepositoryImpl + NativeAppTemplatePreferencesDataSource), as sign-in and the permissions + * response change them. Robolectric for the NavHostController. + */ +@RunWith(RobolectricTestRunner::class) +class NativeAppTemplateAppStateTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + private val dataSource = NativeAppTemplatePreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance())) + private val loginRepository = LoginRepositoryImpl(UnusedLoginApi, dataSource, UnconfinedTestDispatcher()) + private val isOnline = MutableStateFlow(true) + + private fun appState(scope: CoroutineScope) = NativeAppTemplateAppState( + loginRepository = loginRepository, + navController = TestNavHostController(ApplicationProvider.getApplicationContext()), + coroutineScope = scope, + networkMonitor = object : NetworkMonitor { + override val isOnline = this@NativeAppTemplateAppStateTest.isOnline + }, + ) + + // Null until the preferences load, so the app does not route to onboarding on placeholder values. + @Test + fun authDestination_beforeAnyoneCollects_isNull() = testScope.runTest { + assertNull(appState(backgroundScope).authDestination.value) + } + + @Test + fun authDestination_followsSignInAndThePermissions() = testScope.runTest { + val appState = appState(backgroundScope) + backgroundScope.launch { appState.authDestination.collect() } + + assertEquals(AuthDestination.ONBOARDING, appState.authDestination.value) + + dataSource.setShopkeeper(signedInShopkeeper) + assertEquals(AuthDestination.SHOPS, appState.authDestination.value) + + // authDestination(): an app update first, then privacy, then terms. + dataSource.setPermissions(Permissions(meta = Meta(shouldUpdatePrivacy = true, shouldUpdateTerms = true))) + assertEquals(AuthDestination.ACCEPT_PRIVACY, appState.authDestination.value) + + dataSource.setPermissions(Permissions(meta = Meta(androidAppVersion = BuildConfig.VERSION_CODE + 1))) + assertEquals(AuthDestination.NEED_APP_UPDATES, appState.authDestination.value) + + dataSource.clearUserPreferences() + assertEquals(AuthDestination.ONBOARDING, appState.authDestination.value) + } + + @Test + fun shouldShowBottomBar_onlyWhenSignedInWithNothingToAccept() = testScope.runTest { + val appState = appState(backgroundScope) + backgroundScope.launch { appState.shouldShowBottomBar.collect() } + + assertFalse(appState.shouldShowBottomBar.value) + + dataSource.setShopkeeper(signedInShopkeeper) + assertTrue(appState.shouldShowBottomBar.value) + + dataSource.setPermissions(Permissions(meta = Meta(shouldUpdateTerms = true))) + assertFalse(appState.shouldShowBottomBar.value) + } + + @Test + fun isOffline_isTheOppositeOfTheNetworkMonitor() = testScope.runTest { + val appState = appState(backgroundScope) + backgroundScope.launch { appState.isOffline.collect() } + + assertFalse(appState.isOffline.value) + + isOnline.value = false + assertTrue(appState.isOffline.value) + } + + @Test + fun deletionFlags_comeFromThePreferences() = testScope.runTest { + val appState = appState(backgroundScope) + backgroundScope.launch { appState.isShopDeleted.collect() } + backgroundScope.launch { appState.isMyAccountDeleted.collect() } + backgroundScope.launch { appState.isEmailUpdated.collect() } + + dataSource.setIsShopDeleted(true) + dataSource.setIsMyAccountDeleted(true) + dataSource.setIsEmailUpdated(true) + + assertTrue(appState.isShopDeleted.value) + assertTrue(appState.isMyAccountDeleted.value) + assertTrue(appState.isEmailUpdated.value) + } +} + +private val signedInShopkeeper = LoggedInShopkeeper( + datum = Data( + id = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + attributes = Attributes( + accountId = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + personalAccountId = "2140BC6B-1830-45EE-96A4-B4ED5F53AC11", + accountOwnerId = "5712F2DF-DFC7-A3AA-66BC-191203654A1A", + email = "john@example.com", + name = "John Smith", + timeZone = "Tokyo", + token = "token", + client = "client", + uid = "john@example.com", + expiry = "1713165114", + ), + ), +) + +/** The app state only reads the stored preferences; no request is made. */ +private object UnusedLoginApi : LoginApi { + override suspend fun login(data: Login): ApiResponse = error("not used") + override suspend fun logout(): ApiResponse = error("not used") + override suspend fun getPermissions(accountId: String): ApiResponse = error("not used") + override suspend fun updateConfirmedPrivacyVersion(accountId: String): ApiResponse = error("not used") + override suspend fun updateConfirmedTermsVersion(accountId: String): ApiResponse = error("not used") +} diff --git a/model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt b/model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt new file mode 100644 index 0000000..058ebce --- /dev/null +++ b/model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt @@ -0,0 +1,100 @@ +package com.nativeapptemplate.nativeapptemplatefree.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Characterizes `Data.updateRelationships` and the `getDatumWithRelationships()` / `getData()` + * callers as they behave today: the resources in "included" are NOT merged into anything. Each + * resource keeps the relationship references it was decoded with (`{"id", "type"}` only), so the + * calls return values equal to the decoded `data`. These tests pin that down so a refactoring + * (removing the no-op, or making it merge "included") is a deliberate, visible change. + */ +class DataRelationshipsTest { + // NetModule's networkJson settings for decoding. + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + // A JSON:API list response: each item tag refers to its shop, whose attributes are in "included". + private val itemTagsResponse = """ + { + "data": [ + { + "id": "item-tag-1", + "type": "item_tag", + "attributes": {"name": "A001", "state": "idled"}, + "relationships": {"shop": {"data": {"id": "shop-1", "type": "shop"}}} + } + ], + "included": [ + {"id": "shop-1", "type": "shop", "attributes": {"name": "Fox Plaza", "time_zone": "Tokyo"}} + ] + } + """ + + @Test + fun updateRelationships_withIncludedResources_returnsAnEqualCopy() { + val itemTags = json.decodeFromString(itemTagsResponse) + val itemTag = itemTags.datum.single() + + val updated = itemTag.updateRelationships(itemTags.included) + + assertEquals(itemTag, updated) + // The shop reference keeps only what "relationships" carried: the included name is not merged in. + assertEquals("shop-1", updated.relationships?.shop?.datum?.id) + assertNull(updated.relationships?.shop?.datum?.attributes) + } + + @Test + fun updateRelationships_withoutIncludedResources_returnsTheSameInstance() { + val itemTag = json.decodeFromString(itemTagsResponse).datum.single() + + assertSame(itemTag, itemTag.updateRelationships(null)) + assertSame(itemTag, itemTag.updateRelationships(emptyList())) + } + + @Test + fun itemTags_getDatumWithRelationships_isTheDecodedData() { + val itemTags = json.decodeFromString(itemTagsResponse) + + assertEquals(itemTags.datum, itemTags.getDatumWithRelationships()) + } + + @Test + fun shops_getDatumWithRelationships_isTheDecodedData() { + val shops = Shops( + datum = listOf(Data(id = "shop-1", type = "shop", attributes = Attributes(name = "Fox Plaza"), relationships = Relationships(shops = Shops()))), + included = listOf(Data(id = "item-tag-1", type = "item_tag", attributes = Attributes(name = "A001"))), + ) + + assertEquals(shops.datum, shops.getDatumWithRelationships()) + } + + @Test + fun itemTag_getData_withIncludedResources_isTheDecodedData() { + val datum = Data( + id = "item-tag-1", + type = "item_tag", + attributes = Attributes(name = "A001"), + relationships = Relationships(shop = Shop(datum = Data(id = "shop-1", type = "shop"))), + ) + val itemTag = ItemTag(datum = datum, included = listOf(Data(id = "shop-1", type = "shop", attributes = Attributes(name = "Fox Plaza")))) + + assertEquals(datum, itemTag.getData()) + // So getShopName() comes from the item tag's own attributes, not from "included". + assertEquals("", itemTag.getShopName()) + } + + @Test + fun shop_getData_withIncludedResources_isTheDecodedData() { + val datum = Data(id = "shop-1", type = "shop", attributes = Attributes(name = "Fox Plaza"), relationships = Relationships(shops = Shops())) + val shop = Shop(datum = datum, included = listOf(Data(id = "item-tag-1", type = "item_tag"))) + + assertEquals(datum, shop.getData()) + } +} From d9f96e855584131013cb8c8baf49393128ac5be3 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 09:49:26 +0900 Subject: [PATCH 3/3] Cover sign-in and session bootstrap gaps, and drop derived expectations - SignInEmailAndPasswordViewModel: rejected credentials show the coded message and save nothing; a sign-in clears the previous preferences before saving the new session (TestLoginRepository gains failLogin). - MainActivityViewModel.updatePermissions: saves the permissions when signed in, fetches nothing when signed out. - ItemTagListViewModel: a page without pagination meta is the last one; a failed reload does not block loading more. - Expected values no longer come from the code under test: the item tags are compared with the fixtures instead of getDatumWithRelationships() of the same data, and the error messages with literals derived from ApiException instead of codedDescription. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../MainActivityViewModelTest.kt | 33 ++++++++++++++ .../testing/repository/TestLoginRepository.kt | 9 +++- .../SignInEmailAndPasswordViewModelTest.kt | 44 +++++++++++++++++-- .../settings/ShopkeeperEditViewModelTest.kt | 4 +- .../item_tag_list/ItemTagListViewModelTest.kt | 41 ++++++++++++++--- 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt index 568a511..6631b01 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt @@ -5,6 +5,8 @@ import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import com.nativeapptemplate.nativeapptemplatefree.model.Meta +import com.nativeapptemplate.nativeapptemplatefree.model.Permissions import com.nativeapptemplate.nativeapptemplatefree.model.UserData import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAppLanguageProvider import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository @@ -84,6 +86,37 @@ class MainActivityViewModelTest { assertTrue(loginRepository.userData.first().didShowTapShopBelowTip) } + @Test + fun updatePermissions_whenSignedIn_savesThePermissions() = runTest { + loginRepository.sendUserData(UserData()) + loginRepository.sendIsLoggedIn(true) + loginRepository.sendPermissions( + Permissions(meta = Meta(androidAppVersion = 7, shouldUpdatePrivacy = true, shouldUpdateTerms = false, shopLimitCount = 10)), + ) + + viewModel.updatePermissions() + + val userData = loginRepository.userData.first() + assertEquals(7, userData.androidAppVersion) + assertTrue(userData.shouldUpdatePrivacy) + assertEquals(10, userData.shopLimitCount) + } + + @Test + fun updatePermissions_whenSignedOut_fetchesNothing() = runTest { + loginRepository.sendUserData(UserData()) + loginRepository.sendIsLoggedIn(false) + loginRepository.sendPermissions( + Permissions(meta = Meta(androidAppVersion = 7, shouldUpdatePrivacy = true, shouldUpdateTerms = false, shopLimitCount = 10)), + ) + + viewModel.updatePermissions() + + // UserData's defaults: nothing was saved. + assertEquals(-1, loginRepository.userData.first().androidAppVersion) + assertEquals(0, loginRepository.logoutCallCount) + } + @Test fun updatePermissions_whenOffline_keepsTheUserSignedIn() = runTest { loginRepository.sendIsLoggedIn(true) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt index 1da7992..37e9be5 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt @@ -31,7 +31,9 @@ class TestLoginRepository : LoginRepository { override val userData: Flow = _userData.filterNotNull() - override fun login(login: Login): Flow = loggedInShopkeeperFlow + private var loginError: Throwable? = null + + override fun login(login: Login): Flow = loginError?.let { error -> flow { throw error } } ?: loggedInShopkeeperFlow /** How many times [logout] was collected. */ var logoutCallCount = 0 @@ -176,6 +178,11 @@ class TestLoginRepository : LoginRepository { permissionsFlow.tryEmit(permissions) } + /** Makes every later [login] call fail with [error]. */ + fun failLogin(error: Throwable) { + loginError = error + } + /** Makes every later [getPermissions] call fail with [error]. */ fun failPermissions(error: Throwable) { permissionsError = error diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt index ad209bb..7a3efa8 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt @@ -1,7 +1,6 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.app_root import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException -import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data @@ -111,12 +110,51 @@ class SignInEmailAndPasswordViewModelTest { viewModel.login() assertFalse(viewModel.uiState.value.isLoading) - // The user-facing message must be the coded description (AGENTS.md: CodedError system). - assertEquals(error.codedDescription, viewModel.uiState.value.message) + // The user-facing message is the coded description (AGENTS.md: CodedError system): + // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage". + assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: Bad Gateway", viewModel.uiState.value.message) // A server error is not a rejected session; permissions are fetched again on the next resume. assertEquals(0, loginRepository.logoutCallCount) } + @Test + fun login_whenTheCredentialsAreRejected_showsTheServerMessageAndSavesNothing() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!) + viewModel.updatePassword(testInputPassword) + // What throwApiError makes of devise_token_auth's 401 {"errors": ["Invalid login credentials. ..."]}. + loginRepository.failLogin( + ApiException.UnprocessableError(rawMessage = "Invalid login credentials. Please try again.", httpStatusCode = 401), + ) + + viewModel.login() + + val uiStateValue = viewModel.uiState.value + assertFalse(uiStateValue.isLoading) + // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage". + assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: Invalid login credentials. Please try again.", uiStateValue.message) + // The previous session (if any) is left alone, and a rejected sign-in is not a sign-out. + assertEquals(0, loginRepository.clearUserPreferencesCallCount) + assertEquals(0, loginRepository.logoutCallCount) + } + + @Test + fun login_clearsThePreviousPreferencesBeforeSavingTheNewSession() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!) + viewModel.updatePassword(testInputPassword) + loginRepository.sendLoggedInShopkeeper(testInputLoggedInShopkeeper) + loginRepository.sendPermissions(testInputPermissions) + + viewModel.login() + + assertEquals(1, loginRepository.clearUserPreferencesCallCount) + val userData = loginRepository.userData.first() + assertTrue(userData.isLoggedIn) + assertEquals(LOGGED_IN_SHOPKEEPER_ACCOUNT_ID, userData.accountId) + assertEquals(LOGGED_IN_SHOPKEEPER_TOKEN, userData.token) + } + @Test fun login_whenPermissionsAreRejected_logsOut() = runTest { backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt index 6afd518..1fbd314 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt @@ -1,7 +1,6 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.settings import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException -import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper @@ -200,7 +199,8 @@ class ShopkeeperEditViewModelTest { assertEquals(0, loginRepository.clearUserPreferencesCallCount) assertFalse(viewModel.uiState.value.isLoading) assertFalse(viewModel.uiState.value.isDeleted) - assertEquals(error.codedDescription, viewModel.uiState.value.message) + // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage". + assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: Unable to resolve host", viewModel.uiState.value.message) } @Test diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt index 94862f9..0782830 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModelTest.kt @@ -97,13 +97,44 @@ class ItemTagListViewModelTest { assertTrue(uiStateValue.success) assertFalse(uiStateValue.isLoading) - val shopId = testInputShop.datum!!.id!! + // The fixtures the test repositories served. + assertEquals(testInputShop, uiStateValue.shop) + assertEquals(testInputItemTagsData, uiStateValue.itemTags) + } + + // Without "meta" the loaded page counts as the only one: currentPage = the requested page (1), + // totalPages = 1, so there is nothing more to load. + @Test + fun reload_withoutPaginationMeta_treatsThePageAsTheLast() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + shopRepository.sendShop(testInputShop) + itemTagRepository.sendItemTags(ItemTags(datum = testInputItemTagsData, meta = null)) + + viewModel.reload() + viewModel.loadMore() + + val uiStateValue = viewModel.uiState.value + assertEquals(1, uiStateValue.currentPage) + assertEquals(1, uiStateValue.totalPages) + assertFalse(uiStateValue.hasMorePages) + assertEquals(listOf(1), itemTagRepository.requestedPages) + } - val shopFromRepository = shopRepository.getShop(shopId).first() - val itemTagsFromRepository = itemTagRepository.getItemTags(shopId).first() + // A failed reload (page 1) is not a failed next page: it shows the error and leaves loading more allowed. + @Test + fun reload_whenItFails_showsTheErrorWithoutBlockingLoadMore() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + shopRepository.sendShop(testInputShop) + itemTagRepository.fail(ItemTagRepository::getItemTags, testApiError) - assertEquals(shopFromRepository, uiStateValue.shop) - assertEquals(itemTagsFromRepository.getDatumWithRelationships(), uiStateValue.itemTags) + viewModel.reload() + + val uiStateValue = viewModel.uiState.value + assertEquals(TEST_API_ERROR_MESSAGE, uiStateValue.message) + assertFalse(uiStateValue.isLoading) + assertFalse(uiStateValue.success) + assertFalse(uiStateValue.loadMoreFailed) + assertEquals(emptyList(), uiStateValue.itemTags) } @Test