From b32aad63f46fb616ee3a6fd40c82c2975840535e Mon Sep 17 00:00:00 2001 From: dadachi Date: Wed, 29 Apr 2026 16:53:34 +0900 Subject: [PATCH] Add tests for Onboarding model, MainActivityViewModel, AuthInterceptor Mirrors NativeAppTemplate-Android#53. Covers three previously-untested areas: - Onboarding data class + ImageOrientation enum (defaults, equality, enum entries). - MainActivityViewModel uiState mapping (Loading, Success, isLoggedIn reflection) and the didShowTapShopBelowTip toggle persisting through the repository. - AuthInterceptor header injection (with/without auth, base headers, URL preservation), using an in-test RecordingChain so the test runs as a plain JVM unit test. TestLoginRepository.setDidShowTapShopBelowTip now mirrors the other setters and updates the user-data flow so the persistence test can observe it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MainActivityViewModelTest.kt | 69 +++++++++ .../network/AuthInterceptorTest.kt | 137 ++++++++++++++++++ .../testing/repository/TestLoginRepository.kt | 3 + .../ui/app_root/OnboardingTest.kt | 39 +++++ 4 files changed, 248 insertions(+) create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/OnboardingTest.kt diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt new file mode 100644 index 0000000..8feeec6 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt @@ -0,0 +1,69 @@ +package com.nativeapptemplate.nativeapptemplatefree + +import com.nativeapptemplate.nativeapptemplatefree.model.UserData +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class MainActivityViewModelTest { + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val loginRepository = TestLoginRepository() + + private lateinit var viewModel: MainActivityViewModel + + @Before + fun setUp() { + viewModel = MainActivityViewModel(loginRepository = loginRepository) + } + + @Test + fun uiState_initialValue_isLoading() = runTest { + assertEquals(MainActivityUiState.Loading, viewModel.uiState.value) + assertFalse(viewModel.uiState.value.isLoggedIn) + } + + @Test + fun uiState_emitsSuccess_whenUserDataArrives() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + val userData = UserData(isLoggedIn = true, email = "john@example.com") + loginRepository.sendUserData(userData) + + val state = viewModel.uiState.value + assertTrue(state is MainActivityUiState.Success) + assertEquals(userData, (state as MainActivityUiState.Success).userData) + assertTrue(state.isLoggedIn) + } + + @Test + fun uiState_isLoggedInReflectsUserData() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + + loginRepository.sendUserData(UserData(isLoggedIn = false)) + assertFalse(viewModel.uiState.value.isLoggedIn) + + loginRepository.sendUserData(UserData(isLoggedIn = true)) + assertTrue(viewModel.uiState.value.isLoggedIn) + } + + @Test + fun updateDidShowTapShopBelowTip_persistsValue() = runTest { + loginRepository.sendUserData(UserData()) + + viewModel.updateDidShowTapShopBelowTip(true) + + assertTrue(loginRepository.userData.first().didShowTapShopBelowTip) + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt new file mode 100644 index 0000000..e4c8d51 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptorTest.kt @@ -0,0 +1,137 @@ +package com.nativeapptemplate.nativeapptemplatefree.network + +import com.nativeapptemplate.nativeapptemplatefree.UserPreferences +import com.nativeapptemplate.nativeapptemplatefree.datastore.NatPreferencesDataSource +import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.Connection +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.concurrent.TimeUnit + +class AuthInterceptorTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + + private fun dataSourceWith( + token: String, + client: String, + uid: String, + expiry: String, + ): NatPreferencesDataSource { + val initial = UserPreferences.newBuilder() + .setToken(token) + .setClient(client) + .setUid(uid) + .setExpiry(expiry) + .build() + return NatPreferencesDataSource(InMemoryDataStore(initial)) + } + + @Test + fun intercept_withAuthData_addsAuthHeaders() = testScope.runTest { + val dataSource = dataSourceWith( + token = "test-token", + client = "test-client", + uid = "john@example.com", + expiry = "12345", + ) + val interceptor = AuthInterceptor(dataSource) + val chain = RecordingChain(Request.Builder().url("https://example.com/").build()) + + interceptor.intercept(chain) + + val sent = chain.proceededRequest!! + assertEquals("test-token", sent.header("access-token")) + assertEquals("Bearer", sent.header("token-type")) + assertEquals("test-client", sent.header("client")) + assertEquals("12345", sent.header("expiry")) + assertEquals("john@example.com", sent.header("uid")) + } + + @Test + fun intercept_withAuthData_addsBaseHeaders() = testScope.runTest { + val dataSource = dataSourceWith( + token = "test-token", + client = "test-client", + uid = "john@example.com", + expiry = "12345", + ) + val interceptor = AuthInterceptor(dataSource) + val chain = RecordingChain(Request.Builder().url("https://example.com/").build()) + + interceptor.intercept(chain) + + val sent = chain.proceededRequest!! + assertEquals("android", sent.header("source")) + assertEquals("application/vnd.api+json; charset=utf-8", sent.header("Accept")) + assertEquals("application/json", sent.header("Content-Type")) + } + + @Test + fun intercept_withoutAuthData_omitsAuthHeaders() = testScope.runTest { + val dataSource = NatPreferencesDataSource( + InMemoryDataStore(UserPreferences.getDefaultInstance()), + ) + val interceptor = AuthInterceptor(dataSource) + val chain = RecordingChain(Request.Builder().url("https://example.com/").build()) + + interceptor.intercept(chain) + + val sent = chain.proceededRequest!! + assertNull(sent.header("access-token")) + assertNull(sent.header("token-type")) + assertNull(sent.header("client")) + assertNull(sent.header("expiry")) + assertNull(sent.header("uid")) + assertEquals("android", sent.header("source")) + } + + @Test + fun intercept_preservesOriginalRequestUrl() = testScope.runTest { + val dataSource = NatPreferencesDataSource( + InMemoryDataStore(UserPreferences.getDefaultInstance()), + ) + val interceptor = AuthInterceptor(dataSource) + val originalUrl = "https://example.com/path?query=value" + val chain = RecordingChain(Request.Builder().url(originalUrl).build()) + + interceptor.intercept(chain) + + assertEquals(originalUrl, chain.proceededRequest!!.url.toString()) + } +} + +private class RecordingChain(private val request: Request) : Interceptor.Chain { + var proceededRequest: Request? = null + + override fun request(): Request = request + + override fun proceed(request: Request): Response { + proceededRequest = request + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("".toResponseBody(null)) + .build() + } + + override fun connection(): Connection? = null + override fun call(): Call = error("not used") + override fun connectTimeoutMillis(): Int = 0 + override fun withConnectTimeout(timeout: Int, unit: TimeUnit): Interceptor.Chain = error("not used") + override fun readTimeoutMillis(): Int = 0 + override fun withReadTimeout(timeout: Int, unit: TimeUnit): Interceptor.Chain = error("not used") + override fun writeTimeoutMillis(): Int = 0 + override fun withWriteTimeout(timeout: Int, unit: TimeUnit): Interceptor.Chain = error("not used") +} 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 5cdee04..c3f7a21 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 @@ -97,6 +97,9 @@ class TestLoginRepository : LoginRepository { } override suspend fun setDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(didShowTapShopBelowTip = didShowTapShopBelowTip)) + } } override suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/OnboardingTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/OnboardingTest.kt new file mode 100644 index 0000000..e901cf8 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/OnboardingTest.kt @@ -0,0 +1,39 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class OnboardingTest { + @Test + fun onboarding_defaultsToLandscape() { + val onboarding = Onboarding(id = 1) + assertEquals(ImageOrientation.LANDSCAPE, onboarding.imageOrientation) + } + + @Test + fun onboarding_acceptsExplicitOrientation() { + val onboarding = Onboarding(id = 2, imageOrientation = ImageOrientation.PORTRAIT) + assertEquals(ImageOrientation.PORTRAIT, onboarding.imageOrientation) + } + + @Test + fun onboarding_equalsWhenIdAndOrientationMatch() { + val a = Onboarding(id = 3, imageOrientation = ImageOrientation.PORTRAIT) + val b = Onboarding(id = 3, imageOrientation = ImageOrientation.PORTRAIT) + assertEquals(a, b) + } + + @Test + fun onboarding_differsWhenOrientationDiffers() { + val a = Onboarding(id = 4, imageOrientation = ImageOrientation.PORTRAIT) + val b = Onboarding(id = 4, imageOrientation = ImageOrientation.LANDSCAPE) + assertNotEquals(a, b) + } + + @Test + fun imageOrientation_hasPortraitAndLandscape() { + val values = ImageOrientation.entries.map { it.name } + assertEquals(setOf("PORTRAIT", "LANDSCAPE"), values.toSet()) + } +}