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:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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<LoginViewModel>())
|
||||
is AuthState.Authenticated ->
|
||||
AuthState.Loading -> {
|
||||
SplashScreen()
|
||||
}
|
||||
|
||||
AuthState.Unauthenticated -> {
|
||||
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
|
||||
}
|
||||
|
||||
is AuthState.Authenticated -> {
|
||||
PostLoginPlaceholderScreen(
|
||||
user = current.user,
|
||||
viewModel = koinViewModel<PostLoginViewModel>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AuthState.Loading>(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(
|
||||
|
||||
@@ -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,76 +74,43 @@ 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<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)
|
||||
|
||||
// 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<OidcResult>()
|
||||
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>(
|
||||
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(
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user