@@ -5,12 +5,15 @@ import com.getcode.opencode.model.accounts.AccountCluster
55import com.getcode.opencode.model.accounts.AccountFilter
66import com.getcode.opencode.model.accounts.AccountInfo
77import com.getcode.opencode.model.accounts.AccountResponse
8+ import com.getcode.opencode.model.accounts.AccountType
89import com.getcode.opencode.model.core.ID
910import com.getcode.opencode.model.core.errors.GetAccountsError
1011import com.getcode.opencode.model.core.errors.SubmitIntentError
1112import com.getcode.opencode.repositories.AccountRepository
1213import com.getcode.solana.keys.Mint
14+ import com.getcode.solana.keys.PublicKey
1315import com.getcode.utils.network.NetworkConnectivityListener
16+ import io.mockk.every
1417import io.mockk.mockk
1518import kotlinx.coroutines.CoroutineScope
1619import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -70,7 +73,41 @@ class AccountControllerTest {
7073 }
7174
7275 @Test
73- fun `ensureCoreAccount is a no-op when getAccounts already returns accounts` () = runTest {
76+ fun `ensureCoreAccount is a no-op when getAccounts already returns a USDF primary` () = runTest {
77+ val repo = FakeAccountRepository (
78+ onGetAccounts = { Result .success(AccountResponse (accounts = accountsOf(usdfPrimary()))) },
79+ )
80+ val controller = AccountController (repo, networkObserver)
81+
82+ val result = controller.ensureCoreAccount(owner)
83+
84+ assertTrue(result.isSuccess)
85+ assertEquals(0 , repo.createCount)
86+ }
87+
88+ @Test
89+ fun `ensureCoreAccount creates USDF when getAccounts succeeds without a USDF primary` () = runTest {
90+ // Regression: a freshly-onboarded owner whose getAccounts responds OK but does
91+ // not yet contain a USDF core-mint PRIMARY (e.g. only a non-primary or other-mint
92+ // account, or a create still racing the reactive bootstrap). The server recognizes
93+ // an OCP user — and can auto-open currency destinations for direct-send tips — only
94+ // once a USDF primary exists, so onboarding must provision it rather than pass the
95+ // gate on any successful response.
96+ val repo = FakeAccountRepository (
97+ onGetAccounts = {
98+ Result .success(AccountResponse (accounts = accountsOf(usdfPool(), otherMintPrimary())))
99+ },
100+ )
101+ val controller = AccountController (repo, networkObserver)
102+
103+ val result = controller.ensureCoreAccount(owner)
104+
105+ assertTrue(result.isSuccess)
106+ assertEquals(1 , repo.createCount)
107+ }
108+
109+ @Test
110+ fun `ensureCoreAccount creates USDF when getAccounts succeeds with no accounts` () = runTest {
74111 val repo = FakeAccountRepository (
75112 onGetAccounts = { Result .success(AccountResponse (accounts = emptyMap())) },
76113 )
@@ -79,6 +116,87 @@ class AccountControllerTest {
79116 val result = controller.ensureCoreAccount(owner)
80117
81118 assertTrue(result.isSuccess)
119+ assertEquals(1 , repo.createCount)
120+ }
121+
122+ @Test
123+ fun `ensureCoreAccount tolerates a concurrent provision that already opened the core account` () = runTest {
124+ // The create loses a race to the reactive bootstrap (or a duplicate open) and is
125+ // rejected, but a re-fetch shows the USDF primary now exists — onboarding should
126+ // NOT be blocked in that case.
127+ var call = 0
128+ val repo = FakeAccountRepository (
129+ onCreate = { Result .failure(SubmitIntentError .Denied (listOf (" account already exists" ))) },
130+ onGetAccounts = {
131+ call++
132+ if (call == 1 ) {
133+ Result .success(AccountResponse (accounts = emptyMap()))
134+ } else {
135+ Result .success(AccountResponse (accounts = accountsOf(usdfPrimary())))
136+ }
137+ },
138+ )
139+ val controller = AccountController (repo, networkObserver)
140+
141+ val result = controller.ensureCoreAccount(owner)
142+
143+ assertTrue(result.isSuccess)
144+ assertEquals(1 , repo.createCount)
145+ }
146+
147+ @Test
148+ fun `ensureCoreAccount ignores a stale cached USDF primary from a prior account` () = runTest {
149+ // Cross-account bleed regression: the controller cached a prior account's USDF
150+ // primary, then a new account onboards in the same process. The server (source of
151+ // truth) has no accounts for the new owner, so the gate must provision rather than
152+ // short-circuit on the stale cache — otherwise the fresh account is released to the
153+ // scanner with no core account and can't receive a direct-send tip until restart.
154+ val repo = FakeAccountRepository (
155+ onGetAccounts = { Result .success(AccountResponse (accounts = accountsOf(usdfPrimary()))) },
156+ )
157+ val controller = AccountController (repo, networkObserver)
158+
159+ // Seed the cache as if a prior account's accounts had been fetched.
160+ controller.ensureCoreAccount(owner)
82161 assertEquals(0 , repo.createCount)
162+
163+ // New account: server reports NotFound for this owner.
164+ repo.onGetAccounts = { Result .failure(GetAccountsError .NotFound ()) }
165+ val result = controller.ensureCoreAccount(owner)
166+
167+ assertTrue(result.isSuccess)
168+ assertEquals(1 , repo.createCount)
169+ }
170+
171+ @Test
172+ fun `onUserLoggedIn clears cached accounts when the account changes` () = runTest {
173+ val repo = FakeAccountRepository (
174+ onGetAccounts = { Result .success(AccountResponse (accounts = accountsOf(usdfPrimary()))) },
175+ )
176+ val controller = AccountController (repo, networkObserver)
177+ controller.ensureCoreAccount(owner)
178+ assertTrue(controller.hasAccountFor(Mint .usdf))
179+
180+ // A different account signs in; server has nothing for it, so nothing repopulates.
181+ repo.onGetAccounts = { Result .failure(GetAccountsError .NotFound ()) }
182+ controller.onUserLoggedIn(mockk(relaxed = true ))
183+
184+ assertTrue(! controller.hasAccountFor(Mint .usdf))
185+ }
186+
187+ private fun accountsOf (vararg infos : AccountInfo ): Map <PublicKey , AccountInfo > =
188+ infos.associateBy { it.address }
189+
190+ private fun usdfPrimary () = accountInfo(Mint .usdf, AccountType .Primary )
191+ private fun usdfPool () = accountInfo(Mint .usdf, AccountType .Pool )
192+ private fun otherMintPrimary () = accountInfo(Mint .usdc, AccountType .Primary )
193+
194+ private fun accountInfo (accountMint : Mint , type : AccountType ): AccountInfo {
195+ val addr = mockk<PublicKey >()
196+ return mockk<AccountInfo > {
197+ every { address } returns addr
198+ every { mint } returns accountMint
199+ every { accountType } returns type
200+ }
83201 }
84202}
0 commit comments