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