app

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

commit 27b5ed16f83c2dd8a02d4b2ed768435de004479f
parent d119993059159885d75b6c49417533c8bda03a1e
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 18:26:46 +0000

accounts: implement validated account creation

- inject deterministic account identifier generation
- normalize and validate draft values before allocation
- add and select new accounts in a logged-out state
- preserve prior state on typed validation and identifier failures

Diffstat:
Aapp/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountIdFactory.kt | 7+++++++
Aapp/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 211 insertions(+), 0 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountIdFactory.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountIdFactory.kt @@ -0,0 +1,7 @@ +package org.radroots.studio.accounts.state + +import org.radroots.studio.accounts.model.AccountId + +fun interface AccountIdFactory { + fun nextId(): AccountId +} 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 @@ -0,0 +1,62 @@ +package org.radroots.studio.accounts.state + +import org.radroots.studio.accounts.model.Account +import org.radroots.studio.accounts.model.AccountsAction +import org.radroots.studio.accounts.model.AccountsProblem +import org.radroots.studio.accounts.model.AccountsState +import org.radroots.studio.accounts.model.AddAccountDraft +import org.radroots.studio.accounts.model.LoginStatus +import org.radroots.studio.accounts.model.normalizeDisplayName +import org.radroots.studio.accounts.model.normalizeServerUrl +import org.radroots.studio.accounts.model.requireValid + +class AccountsReducer( + private val accountIdFactory: AccountIdFactory, +) { + fun reduce( + state: AccountsState, + action: AccountsAction, + ): AccountsState { + state.requireValid() + val nextState = when (action) { + is AccountsAction.EditAddDisplayName -> state.copy( + addDraft = state.addDraft.copy(displayName = action.value), + ) + is AccountsAction.EditAddServerUrl -> state.copy( + addDraft = state.addDraft.copy(serverUrl = action.value), + ) + AccountsAction.SubmitAddAccount -> submitAddAccount(state) + AccountsAction.DismissProblem -> state.copy(problem = null) + else -> state + } + return nextState.requireValid() + } + + private fun submitAddAccount(state: AccountsState): AccountsState { + val displayName = normalizeDisplayName(state.addDraft.displayName) + ?: return state.copy(problem = AccountsProblem.BlankDisplayName) + val serverUrl = normalizeServerUrl(state.addDraft.serverUrl) + ?: return state.copy(problem = AccountsProblem.InvalidServerUrl) + val accountId = accountIdFactory.nextId() + if (accountId.value.isBlank() || accountId.value != accountId.value.trim()) { + return state.copy(problem = AccountsProblem.InvalidGeneratedAccountId) + } + if (state.accounts.any { it.id == accountId }) { + return state.copy(problem = AccountsProblem.DuplicateAccountId(accountId)) + } + + val account = Account( + id = accountId, + displayName = displayName, + serverUrl = serverUrl, + loginStatus = LoginStatus.LoggedOut, + ) + return state.copy( + accounts = state.accounts + account, + selectedAccountId = accountId, + addDraft = AddAccountDraft(), + pendingRemovalAccountId = null, + 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 @@ -0,0 +1,142 @@ +package org.radroots.studio.accounts.state + +import java.util.ArrayDeque +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +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 +import org.radroots.studio.accounts.model.LoginStatus +import org.radroots.studio.accounts.testAccount +import org.radroots.studio.accounts.testAccountsState + +class AccountsReducerTest { + @Test + fun draftEditsChangeOnlyTheRequestedField() { + val reducer = reducerWithIds("unused") + + val named = reducer.reduce( + AccountsState(), + AccountsAction.EditAddDisplayName("Farm Account"), + ) + val addressed = reducer.reduce( + named, + AccountsAction.EditAddServerUrl("https://farm.example.test"), + ) + + assertEquals("Farm Account", addressed.addDraft.displayName) + assertEquals("https://farm.example.test", addressed.addDraft.serverUrl) + assertEquals(emptyList(), addressed.accounts) + } + + @Test + fun validSubmitNormalizesAddsAndSelectsLoggedOutAccount() { + val factory = RecordingIdFactory("account-1") + val reducer = AccountsReducer(factory) + val state = draftState(" Farm Account ", " HTTPS://FARM.EXAMPLE.TEST/a/../b ") + + val result = reducer.reduce(state, AccountsAction.SubmitAddAccount) + + assertEquals(1, factory.callCount) + assertEquals(AccountId("account-1"), result.selectedAccountId) + assertEquals("Farm Account", result.accounts.single().displayName) + assertEquals("https://farm.example.test/b", result.accounts.single().serverUrl) + assertEquals(LoginStatus.LoggedOut, result.accounts.single().loginStatus) + assertEquals("", result.addDraft.displayName) + assertEquals("", result.addDraft.serverUrl) + assertEquals(null, result.problem) + } + + @Test + fun invalidDraftDoesNotRequestAnIdOrMutateAccounts() { + val factory = RecordingIdFactory("unused") + val reducer = AccountsReducer(factory) + val existing = testAccount() + val base = testAccountsState(existing) + + val blankName = reducer.reduce( + base.copy( + addDraft = base.addDraft.copy(serverUrl = "https://farm.example.test"), + ), + AccountsAction.SubmitAddAccount, + ) + val invalidUrl = reducer.reduce( + base.copy( + addDraft = base.addDraft.copy(displayName = "Second Account", serverUrl = "ftp://host"), + ), + AccountsAction.SubmitAddAccount, + ) + + assertEquals(0, factory.callCount) + assertEquals(base.accounts, blankName.accounts) + assertEquals(base.selectedAccountId, blankName.selectedAccountId) + assertIs<AccountsProblem.BlankDisplayName>(blankName.problem) + assertEquals(base.accounts, invalidUrl.accounts) + assertIs<AccountsProblem.InvalidServerUrl>(invalidUrl.problem) + } + + @Test + fun invalidOrDuplicateGeneratedIdIsAStableFailure() { + val existing = testAccount() + val base = testAccountsState(existing).copy( + addDraft = draftState("Second Account", "https://second.example.test").addDraft, + ) + + val blank = AccountsReducer(RecordingIdFactory(" ")).reduce( + base, + AccountsAction.SubmitAddAccount, + ) + val duplicate = AccountsReducer(RecordingIdFactory(existing.id.value)).reduce( + base, + AccountsAction.SubmitAddAccount, + ) + + assertEquals(base.accounts, blank.accounts) + assertIs<AccountsProblem.InvalidGeneratedAccountId>(blank.problem) + assertEquals(base.accounts, duplicate.accounts) + assertEquals(AccountsProblem.DuplicateAccountId(existing.id), duplicate.problem) + } + + @Test + fun dismissProblemClearsOnlyTheProblem() { + val state = testAccountsState(testAccount()).copy( + problem = AccountsProblem.InvalidServerUrl, + ) + + val result = reducerWithIds("unused").reduce( + state, + AccountsAction.DismissProblem, + ) + + assertEquals(state.copy(problem = null), result) + } + + private fun draftState( + displayName: String, + serverUrl: String, + ) = AccountsState( + addDraft = org.radroots.studio.accounts.model.AddAccountDraft( + displayName = displayName, + serverUrl = serverUrl, + ), + ) + + private fun reducerWithIds(vararg ids: String) = + AccountsReducer(RecordingIdFactory(*ids)) +} + +private class RecordingIdFactory( + vararg ids: String, +) : AccountIdFactory { + private val ids = ArrayDeque(ids.map(::AccountId)) + + var callCount = 0 + private set + + override fun nextId(): AccountId { + callCount += 1 + return ids.removeFirst() + } +}