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.
This commit is contained in:
2026-04-28 17:41:18 +02:00
parent 88f489800d
commit 570652c744
6 changed files with 103 additions and 94 deletions

View File

@@ -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.

View File

@@ -23,12 +23,21 @@ version = "1.0.0"
android { android {
namespace = "dev.ulfrx.recipe" namespace = "dev.ulfrx.recipe"
compileSdk = libs.versions.android.compileSdk.get().toInt() compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
defaultConfig { defaultConfig {
applicationId = "dev.ulfrx.recipe" applicationId = "dev.ulfrx.recipe"
minSdk = libs.versions.android.minSdk.get().toInt() minSdk =
targetSdk = libs.versions.android.targetSdk.get().toInt() libs.versions.android.minSdk
.get()
.toInt()
targetSdk =
libs.versions.android.targetSdk
.get()
.toInt()
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -71,7 +80,9 @@ kotlin {
isStatic = true isStatic = true
} }
pod("AppAuth") { 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)
implementation(libs.multiplatform.settings.coroutines) 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 { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

@@ -3,8 +3,8 @@ package dev.ulfrx.recipe
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.auth.AuthSession import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthState import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.ui.screens.auth.LoginScreen import dev.ulfrx.recipe.ui.screens.auth.LoginScreen
@@ -36,13 +36,20 @@ fun App() {
} }
when (val current = authState) { when (val current = authState) {
AuthState.Loading -> SplashScreen() AuthState.Loading -> {
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>()) SplashScreen()
is AuthState.Authenticated -> }
AuthState.Unauthenticated -> {
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
}
is AuthState.Authenticated -> {
PostLoginPlaceholderScreen( PostLoginPlaceholderScreen(
user = current.user, user = current.user,
viewModel = koinViewModel<PostLoginViewModel>(), viewModel = koinViewModel<PostLoginViewModel>(),
) )
}
} }
} }
} }

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe.auth package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
@@ -10,7 +10,7 @@ import kotlin.test.assertNull
class AuthSessionTest { class AuthSessionTest {
@Test @Test
fun emptyStoreInitializesLoadingToUnauthenticated() { fun emptyStoreInitializesLoadingToUnauthenticated() {
runBlocking { runTest {
val session = newSession(store = FakeAuthStateStore()) val session = newSession(store = FakeAuthStateStore())
assertIs<AuthState.Loading>(session.state.value) assertIs<AuthState.Loading>(session.state.value)
@@ -23,7 +23,7 @@ class AuthSessionTest {
@Test @Test
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() { fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
runBlocking { runTest {
val store = FakeAuthStateStore() val store = FakeAuthStateStore()
val oidcClient = val oidcClient =
FakeOidcClient( FakeOidcClient(
@@ -51,7 +51,7 @@ class AuthSessionTest {
@Test @Test
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() { fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
runBlocking { runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json") val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient = val oidcClient =
FakeOidcClient( FakeOidcClient(
@@ -80,7 +80,7 @@ class AuthSessionTest {
@Test @Test
fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() { fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
runBlocking { runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json") val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient = val oidcClient =
FakeOidcClient( FakeOidcClient(
@@ -97,7 +97,7 @@ class AuthSessionTest {
@Test @Test
fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() { fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() {
runBlocking { runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json") val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient = val oidcClient =
FakeOidcClient( FakeOidcClient(
@@ -114,7 +114,7 @@ class AuthSessionTest {
@Test @Test
fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() { fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() {
runBlocking { runTest {
val store = FakeAuthStateStore(value = AUTH_STATE_JSON) val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
val oidcClient = FakeOidcClient() val oidcClient = FakeOidcClient()
val session = newSession(store = store, oidcClient = oidcClient) val session = newSession(store = store, oidcClient = oidcClient)
@@ -129,7 +129,7 @@ class AuthSessionTest {
@Test @Test
fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() { fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() {
runBlocking { runTest {
val store = FakeAuthStateStore(value = AUTH_STATE_JSON) val store = FakeAuthStateStore(value = AUTH_STATE_JSON)
val oidcClient = FakeOidcClient(logoutThrows = true) val oidcClient = FakeOidcClient(logoutThrows = true)
val session = newSession(store = store, oidcClient = oidcClient) val session = newSession(store = store, oidcClient = oidcClient)
@@ -144,7 +144,7 @@ class AuthSessionTest {
@Test @Test
fun loginCancelledMapsToUiRenderableCancelledResult() { fun loginCancelledMapsToUiRenderableCancelledResult() {
runBlocking { runTest {
val store = FakeAuthStateStore() val store = FakeAuthStateStore()
val session = val session =
newSession( newSession(

View File

@@ -1,6 +1,5 @@
package dev.ulfrx.recipe.ui.screens.auth package dev.ulfrx.recipe.ui.screens.auth
import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.MeGateway 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.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_error_cancelled import recipe.composeapp.generated.resources.auth_error_cancelled
import recipe.composeapp.generated.resources.auth_error_network import recipe.composeapp.generated.resources.auth_error_network
@@ -23,8 +19,8 @@ import kotlin.test.assertTrue
class LoginViewModelTest { class LoginViewModelTest {
@Test @Test
fun cancelledAuthFailureMapsToCancelledStringResource() { fun cancelledAuthFailureMapsToCancelledStringResource() =
runBlocking { runTest {
val session = newSession(loginResult = OidcResult.Cancelled) val session = newSession(loginResult = OidcResult.Cancelled)
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
@@ -33,11 +29,10 @@ class LoginViewModelTest {
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
} }
}
@Test @Test
fun networkAuthFailureMapsToNetworkStringResource() { fun networkAuthFailureMapsToNetworkStringResource() =
runBlocking { runTest {
val session = newSession(loginResult = OidcResult.NetworkError) val session = newSession(loginResult = OidcResult.NetworkError)
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
@@ -46,11 +41,10 @@ class LoginViewModelTest {
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
} }
}
@Test @Test
fun unknownAuthFailureMapsToUnknownStringResource() { fun unknownAuthFailureMapsToUnknownStringResource() =
runBlocking { runTest {
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed")) val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
val viewModel = LoginViewModel(session) val viewModel = LoginViewModel(session)
@@ -59,11 +53,10 @@ class LoginViewModelTest {
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
} }
}
@Test @Test
fun successClearsErrorAndStopsLoading() { fun successClearsErrorAndStopsLoading() =
runBlocking { runTest {
val session = val session =
newSession( newSession(
loginResult = loginResult =
@@ -81,77 +74,44 @@ class LoginViewModelTest {
assertNull(viewModel.state.value.errorKey) assertNull(viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading) assertEquals(false, viewModel.state.value.isLoading)
} }
}
@Test @Test
fun startingNewSignInClearsPreviousErrorAndSetsLoading() { fun startingNewSignInClearsPreviousErrorAndSetsLoading() =
runBlocking { runTest {
// First login fails with cancelled to seed an error. // Queue: first login resolves Cancelled to seed an inline error.
val firstOidc = FakeOidcClient(loginResult = OidcResult.Cancelled) // Second login awaits a gate so we can synchronously observe the
val session = AuthSession(firstOidc, FakeAuthStateStore(), FakeMeClient(USER)) // "loading=true, error=null" intermediate state contract from UI-SPEC.
val gate = CompletableDeferred<OidcResult>()
val queue = mutableListOf<OidcResult>(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) val viewModel = LoginViewModel(session)
// First attempt: error seeded.
viewModel.onSignInClick().join() viewModel.onSignInClick().join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
// Second login: gate the result so we can observe loading=true and error cleared. // Second attempt: launching the job sets loading=true + clears error
val gate = CompletableDeferred<OidcResult>() // BEFORE suspending. onSignInClick() does that synchronously before
val gatedOidc = // returning the launched Job, so we can assert immediately.
object : OidcClientGateway { val job = viewModel.onSignInClick()
override suspend fun login(): OidcResult = gate.await() assertTrue(viewModel.state.value.isLoading)
assertNull(viewModel.state.value.errorKey)
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>(
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)
// Release the gate; the second login also returns Cancelled.
gate.complete(OidcResult.Cancelled) gate.complete(OidcResult.Cancelled)
job.await() job.join()
// After completion, loading is false and the new (still cancelled) error is set. assertEquals(false, viewModel.state.value.isLoading)
assertEquals(false, singleVm.state.value.isLoading) assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(Res.string.auth_error_cancelled, singleVm.state.value.errorKey)
} }
}
private fun newSession( private fun newSession(
loginResult: OidcResult, loginResult: OidcResult,

View File

@@ -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-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" } 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-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" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }