app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 82353b2e2157ce69e994357fd675816605a495e2
parent 27b5ed16f83c2dd8a02d4b2ed768435de004479f
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 18:27:40 +0000

accounts: implement explicit account status actions

- select accounts only through validated identifiers
- target login and logout actions by explicit account ID
- preserve unrelated accounts and current selection
- return typed failures for every missing target

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt | 44++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 103 insertions(+), 0 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt @@ -1,6 +1,7 @@ package org.radroots.studio.accounts.state import org.radroots.studio.accounts.model.Account +import org.radroots.studio.accounts.model.AccountId import org.radroots.studio.accounts.model.AccountsAction import org.radroots.studio.accounts.model.AccountsProblem import org.radroots.studio.accounts.model.AccountsState @@ -26,6 +27,17 @@ class AccountsReducer( addDraft = state.addDraft.copy(serverUrl = action.value), ) AccountsAction.SubmitAddAccount -> submitAddAccount(state) + is AccountsAction.SelectAccount -> selectAccount(state, action) + is AccountsAction.LogIn -> setLoginStatus( + state = state, + accountId = action.accountId, + loginStatus = LoginStatus.LoggedIn, + ) + is AccountsAction.LogOut -> setLoginStatus( + state = state, + accountId = action.accountId, + loginStatus = LoginStatus.LoggedOut, + ) AccountsAction.DismissProblem -> state.copy(problem = null) else -> state } @@ -59,4 +71,36 @@ class AccountsReducer( problem = null, ) } + + private fun selectAccount( + state: AccountsState, + action: AccountsAction.SelectAccount, + ): AccountsState = if (state.accounts.any { it.id == action.accountId }) { + state.copy( + selectedAccountId = action.accountId, + problem = null, + ) + } else { + state.copy(problem = AccountsProblem.AccountNotFound(action.accountId)) + } + + private fun setLoginStatus( + state: AccountsState, + accountId: AccountId, + loginStatus: LoginStatus, + ): AccountsState { + if (state.accounts.none { it.id == accountId }) { + return state.copy(problem = AccountsProblem.AccountNotFound(accountId)) + } + return state.copy( + accounts = state.accounts.map { account -> + if (account.id == accountId) { + account.copy(loginStatus = loginStatus) + } else { + account + } + }, + problem = null, + ) + } } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt @@ -113,6 +113,65 @@ class AccountsReducerTest { assertEquals(state.copy(problem = null), result) } + @Test + fun selectionTargetsAnExplicitExistingAccount() { + val first = testAccount() + val second = testAccount(id = "account-2", displayName = "Second Account") + val base = testAccountsState(first, second) + val reducer = reducerWithIds("unused") + + val selected = reducer.reduce( + base.copy(problem = AccountsProblem.InvalidServerUrl), + AccountsAction.SelectAccount(second.id), + ) + val missingId = AccountId("missing") + val missing = reducer.reduce( + selected, + AccountsAction.SelectAccount(missingId), + ) + + assertEquals(second.id, selected.selectedAccountId) + assertEquals(null, selected.problem) + assertEquals(second.id, missing.selectedAccountId) + assertEquals(AccountsProblem.AccountNotFound(missingId), missing.problem) + } + + @Test + fun loginAndLogoutUpdateOnlyTheExplicitAccount() { + val first = testAccount() + val second = testAccount(id = "account-2", displayName = "Second Account") + val base = testAccountsState(first, second) + val reducer = reducerWithIds("unused") + + val loggedIn = reducer.reduce(base, AccountsAction.LogIn(second.id)) + val idempotentLogin = reducer.reduce(loggedIn, AccountsAction.LogIn(second.id)) + val loggedOut = reducer.reduce(idempotentLogin, AccountsAction.LogOut(second.id)) + val idempotentLogout = reducer.reduce(loggedOut, AccountsAction.LogOut(second.id)) + + assertEquals(LoginStatus.LoggedOut, loggedIn.accounts[0].loginStatus) + assertEquals(LoginStatus.LoggedIn, loggedIn.accounts[1].loginStatus) + assertEquals(loggedIn, idempotentLogin) + assertEquals(LoginStatus.LoggedOut, loggedOut.accounts[1].loginStatus) + assertEquals(loggedOut, idempotentLogout) + assertEquals(first.id, loggedOut.selectedAccountId) + } + + @Test + fun loginAndLogoutRejectMissingTargets() { + val base = testAccountsState(testAccount()) + val missingId = AccountId("missing") + val reducer = reducerWithIds("unused") + + listOf( + reducer.reduce(base, AccountsAction.LogIn(missingId)), + reducer.reduce(base, AccountsAction.LogOut(missingId)), + ).forEach { result -> + assertEquals(base.accounts, result.accounts) + assertEquals(base.selectedAccountId, result.selectedAccountId) + assertEquals(AccountsProblem.AccountNotFound(missingId), result.problem) + } + } + private fun draftState( displayName: String, serverUrl: String,