test(02-07): add Compose Resources, theme seed, and failing LoginViewModel tests
- Add Phase 2 auth strings.xml with Polish scaffold copy from UI-SPEC - Add RecipeTheme with #3B6939 / #A2D597 seed and isSystemInDarkTheme - Add LoginViewModelTest covering cancelled/network/unknown error mapping and the clear-error-on-retry behavior; tests fail compile until Task 2
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Phase 2 auth scaffold copy. Polish-only v1; resources are multi-locale-ready per
|
||||
CLAUDE.md non-negotiable #9. Phase 11 polishes copy + plurals; do not edit
|
||||
these keys without coordinating with the auth UI in `ui/screens/auth/*`.
|
||||
-->
|
||||
<resources>
|
||||
<string name="auth_app_name">Recipe</string>
|
||||
<string name="auth_sign_in_button">Zaloguj się przez Authentik</string>
|
||||
<string name="auth_sign_out_button">Wyloguj się</string>
|
||||
<string name="auth_welcome_format">Witaj, %1$s!</string>
|
||||
<string name="auth_error_cancelled">Logowanie anulowane. Spróbuj ponownie.</string>
|
||||
<string name="auth_error_network">Nie można połączyć z Authentik. Sprawdź połączenie.</string>
|
||||
<string name="auth_error_unknown">Coś poszło nie tak. Spróbuj ponownie.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,35 @@
|
||||
package dev.ulfrx.recipe.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Phase 2 seed theme. Material 3 light/dark schemes with a single seed override on `primary`
|
||||
* (`#3B6939` light / `#A2D597` dark — see `02-UI-SPEC.md` § Color). All other roles use
|
||||
* Material 3 baseline values. Phase 11 may rebase the palette around a different seed.
|
||||
*
|
||||
* Intentionally minimal: no Haze, no custom typography, no shapes. Per UI-SPEC, Material 3
|
||||
* defaults satisfy Phase 2's spacing/typography/accessibility contract.
|
||||
*/
|
||||
private val LightColors =
|
||||
lightColorScheme(
|
||||
primary = Color(0xFF3B6939),
|
||||
)
|
||||
|
||||
private val DarkColors =
|
||||
darkColorScheme(
|
||||
primary = Color(0xFFA2D597),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RecipeTheme(content: @Composable () -> Unit) {
|
||||
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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
|
||||
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 recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.auth_error_cancelled
|
||||
import recipe.composeapp.generated.resources.auth_error_network
|
||||
import recipe.composeapp.generated.resources.auth_error_unknown
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LoginViewModelTest {
|
||||
@Test
|
||||
fun cancelledAuthFailureMapsToCancelledStringResource() {
|
||||
runBlocking {
|
||||
val session = newSession(loginResult = OidcResult.Cancelled)
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick().join()
|
||||
|
||||
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkAuthFailureMapsToNetworkStringResource() {
|
||||
runBlocking {
|
||||
val session = newSession(loginResult = OidcResult.NetworkError)
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick().join()
|
||||
|
||||
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownAuthFailureMapsToUnknownStringResource() {
|
||||
runBlocking {
|
||||
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick().join()
|
||||
|
||||
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
|
||||
assertEquals(false, viewModel.state.value.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successClearsErrorAndStopsLoading() {
|
||||
runBlocking {
|
||||
val session =
|
||||
newSession(
|
||||
loginResult =
|
||||
OidcResult.Success(
|
||||
authStateJson = "{}",
|
||||
accessToken = "access",
|
||||
idToken = null,
|
||||
expiresAtEpochMillis = 0L,
|
||||
),
|
||||
)
|
||||
val viewModel = LoginViewModel(session)
|
||||
|
||||
viewModel.onSignInClick().join()
|
||||
|
||||
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))
|
||||
val viewModel = LoginViewModel(session)
|
||||
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)
|
||||
|
||||
gate.complete(OidcResult.Cancelled)
|
||||
job.await()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun newSession(
|
||||
loginResult: OidcResult,
|
||||
store: AuthStateStore = FakeAuthStateStore(),
|
||||
meClient: MeGateway = FakeMeClient(USER),
|
||||
): AuthSession =
|
||||
AuthSession(
|
||||
oidcClient = FakeOidcClient(loginResult = loginResult),
|
||||
store = store,
|
||||
meClient = meClient,
|
||||
)
|
||||
|
||||
private class FakeAuthStateStore(
|
||||
var value: String? = null,
|
||||
) : AuthStateStore {
|
||||
override fun read(): String? = value
|
||||
|
||||
override fun write(authStateJson: String) {
|
||||
value = authStateJson
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
value = null
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeOidcClient(
|
||||
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
|
||||
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
|
||||
) : OidcClientGateway {
|
||||
override suspend fun login(): OidcResult = loginResult
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
|
||||
|
||||
override suspend fun logout(authStateJson: String) {}
|
||||
}
|
||||
|
||||
private class FakeMeClient(
|
||||
private val user: User,
|
||||
) : MeGateway {
|
||||
override suspend fun getMe(accessToken: String?): User = user
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val USER =
|
||||
User(
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
sub = "authentik-sub",
|
||||
email = "user@example.invalid",
|
||||
displayName = "Recipe User",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user