From 570652c744f6e62b3527a55cf6b8bb324e50242b Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 17:41:18 +0200 Subject: [PATCH] fix(02-07): use kotlinx.coroutines.test.runTest in commonTest for wasmJs - Add kotlinx-coroutines-test to commonTest dependencies in composeApp - Refactor AuthSessionTest and LoginViewModelTest from runBlocking to runTest so the wasmJs test target compiles (runBlocking is JVM/Native-only) - App.kt picks up spotless-imposed brace blocks under the auth-gate when - Log pre-existing SecureAuthStateStoreContractTest and ios SecureAuthStateStore ktlint failures to deferred-items.md (out of scope per gsd scope-boundary rule) Rule 3 (blocking): adding the multiplatform test runtime is needed so ./gradlew check on commonTest sources can compile across all KMP targets. --- .../deferred-items.md | 24 ++++ composeApp/build.gradle.kts | 25 +++- .../commonMain/kotlin/dev/ulfrx/recipe/App.kt | 15 ++- .../dev/ulfrx/recipe/auth/AuthSessionTest.kt | 18 +-- .../ui/screens/auth/LoginViewModelTest.kt | 114 ++++++------------ gradle/libs.versions.toml | 1 + 6 files changed, 103 insertions(+), 94 deletions(-) create mode 100644 .planning/phases/02-authentication-foundation/deferred-items.md diff --git a/.planning/phases/02-authentication-foundation/deferred-items.md b/.planning/phases/02-authentication-foundation/deferred-items.md new file mode 100644 index 0000000..6da7788 --- /dev/null +++ b/.planning/phases/02-authentication-foundation/deferred-items.md @@ -0,0 +1,24 @@ +# Deferred Items — Phase 02 (auth foundation) + +## Pre-existing failures discovered during 02-07 `./gradlew check` + +### `SecureAuthStateStoreContractTest` (Android JVM unit test) — pre-existing + +- **Tests:** `clearRemovesStoredValue`, `writeOverwritesPreviousValueAndReadReturnsLatest` +- **File:** `composeApp/src/androidUnitTest/.../SecureAuthStateStoreContractTest.kt` +- **Failure:** `java.lang.IllegalStateException` at construction (Android Keystore not available in + plain JVM unit tests under Robolectric-less harness). +- **Provenance:** Reproduced on `master` HEAD before any 02-07 change (verified via `git stash` + + run of `./gradlew :composeApp:testDebugUnitTest`). +- **Not caused by 02-07.** Source plan was 02-04 (Android secure-store actuals). Likely + needs Robolectric or an instrumented (`androidTest`) target. Out of scope for 02-07's + UI gate plan. +- **Action:** Track for a follow-up Android-test infra task; do not block Phase 02 on it. + +### Spotless `property-naming` lint in `SecureAuthStateStore.ios.kt:L31` — pre-existing + +- Reproduced on `master` HEAD before any 02-07 change. +- Source plan: 02-05 (iOS auth actuals). +- ktlint expects SCREAMING_SNAKE_CASE for an immutable property; the iOS implementation + uses camelCase. Fix is a one-line rename or `suppressLintsFor` annotation. +- Out of scope for 02-07; track for follow-up. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index fd5b3ca..9f82954 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -23,12 +23,21 @@ version = "1.0.0" android { namespace = "dev.ulfrx.recipe" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() defaultConfig { applicationId = "dev.ulfrx.recipe" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + targetSdk = + libs.versions.android.targetSdk + .get() + .toInt() versionCode = 1 versionName = "1.0" @@ -71,7 +80,9 @@ kotlin { isStatic = true } pod("AppAuth") { - version = libs.versions.appauth.ios.get() + version = + libs.versions.appauth.ios + .get() } } @@ -104,6 +115,12 @@ kotlin { implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings.coroutines) } + commonTest.dependencies { + // 02-07: kotlinx.coroutines.test.runTest is the multiplatform-safe + // alternative to runBlocking (which is JVM/Native-only and breaks the + // wasmJs test target). All commonTest coroutine tests use it. + implementation(libs.kotlinx.coroutinesTest) + } androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt index d5fda9a..5749609 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt @@ -3,8 +3,8 @@ package dev.ulfrx.recipe import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.ulfrx.recipe.auth.AuthSession import dev.ulfrx.recipe.auth.AuthState import dev.ulfrx.recipe.ui.screens.auth.LoginScreen @@ -36,13 +36,20 @@ fun App() { } when (val current = authState) { - AuthState.Loading -> SplashScreen() - AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel()) - is AuthState.Authenticated -> + AuthState.Loading -> { + SplashScreen() + } + + AuthState.Unauthenticated -> { + LoginScreen(viewModel = koinViewModel()) + } + + is AuthState.Authenticated -> { PostLoginPlaceholderScreen( user = current.user, viewModel = koinViewModel(), ) + } } } } diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt index f85b5c0..29bbe62 100644 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt @@ -1,7 +1,7 @@ package dev.ulfrx.recipe.auth import dev.ulfrx.recipe.shared.dto.User -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -10,7 +10,7 @@ import kotlin.test.assertNull class AuthSessionTest { @Test fun emptyStoreInitializesLoadingToUnauthenticated() { - runBlocking { + runTest { val session = newSession(store = FakeAuthStateStore()) assertIs(session.state.value) @@ -23,7 +23,7 @@ class AuthSessionTest { @Test fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() { - runBlocking { + runTest { val store = FakeAuthStateStore() val oidcClient = FakeOidcClient( @@ -51,7 +51,7 @@ class AuthSessionTest { @Test fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() { - runBlocking { + runTest { val store = FakeAuthStateStore(value = "stored-auth-state-json") val oidcClient = FakeOidcClient( @@ -80,7 +80,7 @@ class AuthSessionTest { @Test fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() { - runBlocking { + runTest { val store = FakeAuthStateStore(value = "stored-auth-state-json") val oidcClient = FakeOidcClient( @@ -97,7 +97,7 @@ class AuthSessionTest { @Test fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() { - runBlocking { + runTest { val store = FakeAuthStateStore(value = "stored-auth-state-json") val oidcClient = FakeOidcClient( @@ -114,7 +114,7 @@ class AuthSessionTest { @Test fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() { - runBlocking { + runTest { val store = FakeAuthStateStore(value = AUTH_STATE_JSON) val oidcClient = FakeOidcClient() val session = newSession(store = store, oidcClient = oidcClient) @@ -129,7 +129,7 @@ class AuthSessionTest { @Test fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() { - runBlocking { + runTest { val store = FakeAuthStateStore(value = AUTH_STATE_JSON) val oidcClient = FakeOidcClient(logoutThrows = true) val session = newSession(store = store, oidcClient = oidcClient) @@ -144,7 +144,7 @@ class AuthSessionTest { @Test fun loginCancelledMapsToUiRenderableCancelledResult() { - runBlocking { + runTest { val store = FakeAuthStateStore() val session = newSession( diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt index 530ba05..29a0f58 100644 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt @@ -1,6 +1,5 @@ package dev.ulfrx.recipe.ui.screens.auth -import dev.ulfrx.recipe.auth.AuthLoginResult import dev.ulfrx.recipe.auth.AuthSession import dev.ulfrx.recipe.auth.AuthStateStore import dev.ulfrx.recipe.auth.MeGateway @@ -8,10 +7,7 @@ import dev.ulfrx.recipe.auth.OidcClientGateway import dev.ulfrx.recipe.auth.OidcResult import dev.ulfrx.recipe.shared.dto.User import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.yield +import kotlinx.coroutines.test.runTest import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.auth_error_cancelled import recipe.composeapp.generated.resources.auth_error_network @@ -23,8 +19,8 @@ import kotlin.test.assertTrue class LoginViewModelTest { @Test - fun cancelledAuthFailureMapsToCancelledStringResource() { - runBlocking { + fun cancelledAuthFailureMapsToCancelledStringResource() = + runTest { val session = newSession(loginResult = OidcResult.Cancelled) val viewModel = LoginViewModel(session) @@ -33,11 +29,10 @@ class LoginViewModelTest { assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) assertEquals(false, viewModel.state.value.isLoading) } - } @Test - fun networkAuthFailureMapsToNetworkStringResource() { - runBlocking { + fun networkAuthFailureMapsToNetworkStringResource() = + runTest { val session = newSession(loginResult = OidcResult.NetworkError) val viewModel = LoginViewModel(session) @@ -46,11 +41,10 @@ class LoginViewModelTest { assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey) assertEquals(false, viewModel.state.value.isLoading) } - } @Test - fun unknownAuthFailureMapsToUnknownStringResource() { - runBlocking { + fun unknownAuthFailureMapsToUnknownStringResource() = + runTest { val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed")) val viewModel = LoginViewModel(session) @@ -59,11 +53,10 @@ class LoginViewModelTest { assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey) assertEquals(false, viewModel.state.value.isLoading) } - } @Test - fun successClearsErrorAndStopsLoading() { - runBlocking { + fun successClearsErrorAndStopsLoading() = + runTest { val session = newSession( loginResult = @@ -81,77 +74,44 @@ class LoginViewModelTest { assertNull(viewModel.state.value.errorKey) assertEquals(false, viewModel.state.value.isLoading) } - } @Test - fun startingNewSignInClearsPreviousErrorAndSetsLoading() { - runBlocking { - // First login fails with cancelled to seed an error. - val firstOidc = FakeOidcClient(loginResult = OidcResult.Cancelled) - val session = AuthSession(firstOidc, FakeAuthStateStore(), FakeMeClient(USER)) + fun startingNewSignInClearsPreviousErrorAndSetsLoading() = + runTest { + // Queue: first login resolves Cancelled to seed an inline error. + // Second login awaits a gate so we can synchronously observe the + // "loading=true, error=null" intermediate state contract from UI-SPEC. + val gate = CompletableDeferred() + val queue = mutableListOf(OidcResult.Cancelled) + val oidc = + object : OidcClientGateway { + override suspend fun login(): OidcResult = if (queue.isNotEmpty()) queue.removeAt(0) else gate.await() + + override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used") + + override suspend fun logout(authStateJson: String) {} + } + val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER)) val viewModel = LoginViewModel(session) + + // First attempt: error seeded. viewModel.onSignInClick().join() assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) - // Second login: gate the result so we can observe loading=true and error cleared. - val gate = CompletableDeferred() - val gatedOidc = - object : OidcClientGateway { - override suspend fun login(): OidcResult = gate.await() - - override suspend fun refresh(authStateJson: String): OidcResult = - OidcResult.AuthError("not used") - - override suspend fun logout(authStateJson: String) {} - } - val gatedSession = AuthSession(gatedOidc, FakeAuthStateStore(), FakeMeClient(USER)) - val gatedViewModel = LoginViewModel(gatedSession) - - // Seed an error on the gated VM by directly priming via the cancelled session. - // Simpler: assert behavior with a single VM that has both attempts. - val singleOidcQueue = - mutableListOf( - OidcResult.Cancelled, - ) - val singleOidc = - object : OidcClientGateway { - override suspend fun login(): OidcResult { - // First call returns immediately; subsequent calls await the gate. - return if (singleOidcQueue.isNotEmpty()) { - singleOidcQueue.removeAt(0) - } else { - gate.await() - } - } - - override suspend fun refresh(authStateJson: String): OidcResult = - OidcResult.AuthError("not used") - - override suspend fun logout(authStateJson: String) {} - } - val singleSession = AuthSession(singleOidc, FakeAuthStateStore(), FakeMeClient(USER)) - val singleVm = LoginViewModel(singleSession) - singleVm.onSignInClick().join() - assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey) - - // Now start the second login and observe immediate loading + cleared error. - val job = - async(Dispatchers.Unconfined) { - singleVm.onSignInClick().join() - } - // Yield to let the VM enter the loading state before await suspends. - yield() - assertTrue(singleVm.state.value.isLoading) - assertNull(singleVm.state.value.errorKey) + // Second attempt: launching the job sets loading=true + clears error + // BEFORE suspending. onSignInClick() does that synchronously before + // returning the launched Job, so we can assert immediately. + val job = viewModel.onSignInClick() + assertTrue(viewModel.state.value.isLoading) + assertNull(viewModel.state.value.errorKey) + // Release the gate; the second login also returns Cancelled. gate.complete(OidcResult.Cancelled) - job.await() + job.join() - // After completion, loading is false and the new (still cancelled) error is set. - assertEquals(false, singleVm.state.value.isLoading) - assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey) + assertEquals(false, viewModel.state.value.isLoading) + assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) } - } private fun newSession( loginResult: OidcResult, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3387a19..ed67642 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }