Skip to content

Commit ef45a9a

Browse files
committed
refactor(self-custodial): extract SDK event helpers, network validation and transaction appender
1 parent 8e60f76 commit ef45a9a

13 files changed

Lines changed: 419 additions & 102 deletions

__tests__/self-custodial/config.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,16 @@ describe("SparkConfig", () => {
6565
expect(SparkConfig.apiKey).toBe("my-key")
6666
expect(SparkConfig.tokenIdentifier).toBe("my-token")
6767
})
68+
69+
it("exports SparkNetworkLabel as 'mainnet' for mainnet", () => {
70+
const { SparkNetworkLabel } = loadConfig()
71+
72+
expect(SparkNetworkLabel).toBe("mainnet")
73+
})
74+
75+
it("exports SparkNetworkLabel as 'regtest' for regtest", () => {
76+
const { SparkNetworkLabel } = loadConfig({ BREEZ_NETWORK: "regtest" })
77+
78+
expect(SparkNetworkLabel).toBe("regtest")
79+
})
6880
})

__tests__/self-custodial/mappers/transaction-mapper.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { AccountType } from "@app/types/wallet.types"
88

99
import {
10+
mapCurrency,
1011
mapSelfCustodialTransaction,
1112
mapSelfCustodialTransactions,
1213
} from "@app/self-custodial/mappers/transaction-mapper"
@@ -160,6 +161,20 @@ describe("mapSelfCustodialTransactions", () => {
160161
})
161162
})
162163

164+
describe("mapCurrency", () => {
165+
it("maps Token payment details to USD", () => {
166+
expect(mapCurrency({ tag: "Token", inner: {} } as never)).toBe(WalletCurrency.Usd)
167+
})
168+
169+
it("maps Lightning payment details to BTC", () => {
170+
expect(mapCurrency({ tag: "Lightning", inner: {} } as never)).toBe(WalletCurrency.Btc)
171+
})
172+
173+
it("defaults to BTC when details are undefined", () => {
174+
expect(mapCurrency(undefined)).toBe(WalletCurrency.Btc)
175+
})
176+
})
177+
163178
describe("edge cases", () => {
164179
it("handles zero-amount payment", () => {
165180
const payment = createPayment({ amount: BigInt(0) })
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
extractPaymentId,
3+
REFRESH_EVENTS,
4+
PAYMENT_RECEIVED_EVENTS,
5+
} from "@app/self-custodial/providers/sdk-events"
6+
7+
describe("sdk-events", () => {
8+
describe("REFRESH_EVENTS", () => {
9+
it("includes expected event tags", () => {
10+
expect(REFRESH_EVENTS.size).toBeGreaterThanOrEqual(5)
11+
})
12+
13+
it("does not include PaymentFailed", () => {
14+
const tags = [...REFRESH_EVENTS]
15+
expect(tags).not.toContain("PaymentFailed")
16+
})
17+
})
18+
19+
describe("PAYMENT_RECEIVED_EVENTS", () => {
20+
it("has at least 2 events", () => {
21+
expect(PAYMENT_RECEIVED_EVENTS.size).toBe(2)
22+
})
23+
})
24+
25+
describe("extractPaymentId", () => {
26+
it("extracts id from payment event", () => {
27+
const event = {
28+
tag: "PaymentSucceeded",
29+
inner: { payment: { id: "pay-123" } },
30+
}
31+
32+
expect(extractPaymentId(event)).toBe("pay-123")
33+
})
34+
35+
it("returns null when no inner", () => {
36+
expect(extractPaymentId({ tag: "Synced" })).toBeNull()
37+
})
38+
39+
it("returns null when inner has no payment", () => {
40+
expect(extractPaymentId({ tag: "Synced", inner: { other: "data" } })).toBeNull()
41+
})
42+
43+
it("returns null when inner is not an object", () => {
44+
expect(extractPaymentId({ tag: "Synced", inner: "string" })).toBeNull()
45+
})
46+
})
47+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { validateStoredNetwork } from "@app/self-custodial/providers/validate-network"
2+
3+
const mockGetMnemonicNetwork = jest.fn()
4+
5+
jest.mock("@app/utils/storage/secureStorage", () => ({
6+
__esModule: true,
7+
default: {
8+
getMnemonicNetwork: () => mockGetMnemonicNetwork(),
9+
},
10+
}))
11+
12+
jest.mock("@app/self-custodial/config", () => ({
13+
SparkNetworkLabel: "regtest",
14+
}))
15+
16+
jest.mock("@app/self-custodial/logging", () => ({
17+
logSdkEvent: jest.fn(),
18+
SdkLogLevel: { Error: "error" },
19+
}))
20+
21+
jest.mock("@react-native-firebase/crashlytics", () => () => ({
22+
recordError: jest.fn(),
23+
}))
24+
25+
describe("validateStoredNetwork", () => {
26+
beforeEach(() => {
27+
jest.clearAllMocks()
28+
})
29+
30+
it("returns true when no stored network (legacy wallets)", async () => {
31+
mockGetMnemonicNetwork.mockResolvedValue(null)
32+
33+
expect(await validateStoredNetwork()).toBe(true)
34+
})
35+
36+
it("returns true when stored network matches config", async () => {
37+
mockGetMnemonicNetwork.mockResolvedValue("regtest")
38+
39+
expect(await validateStoredNetwork()).toBe(true)
40+
})
41+
42+
it("returns false on network mismatch", async () => {
43+
mockGetMnemonicNetwork.mockResolvedValue("mainnet")
44+
45+
expect(await validateStoredNetwork()).toBe(false)
46+
})
47+
})

__tests__/self-custodial/providers/wallet-provider.spec.tsx

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react"
22
import { Text } from "react-native"
3-
import { render, renderHook, waitFor } from "@testing-library/react-native"
3+
import { act, render, renderHook, waitFor } from "@testing-library/react-native"
44

55
import { AccountType, ActiveWalletStatus } from "@app/types/wallet.types"
66

@@ -60,10 +60,17 @@ jest.mock("@react-native-firebase/crashlytics", () => () => ({
6060

6161
jest.mock("@app/self-custodial/config", () => ({
6262
SparkConfig: { network: 1 },
63+
SparkNetworkLabel: "regtest",
64+
}))
65+
66+
jest.mock("@app/self-custodial/providers/validate-network", () => ({
67+
validateStoredNetwork: jest.fn().mockResolvedValue(true),
6368
}))
6469

6570
jest.mock("@app/self-custodial/providers/wallet-snapshot", () => ({
6671
getSelfCustodialWalletSnapshot: jest.fn().mockResolvedValue([]),
72+
loadMoreTransactions: jest.fn().mockResolvedValue({ transactions: [], hasMore: false }),
73+
appendTransactions: jest.fn().mockImplementation((wallets: unknown) => wallets),
6774
}))
6875

6976
const wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -119,8 +126,11 @@ describe("SelfCustodialWalletProvider", () => {
119126
})
120127

121128
it("sets error status on network mismatch", async () => {
129+
const mockValidate = jest.requireMock(
130+
"@app/self-custodial/providers/validate-network",
131+
).validateStoredNetwork
132+
mockValidate.mockResolvedValueOnce(false)
122133
mockGetMnemonic.mockResolvedValue("word1 word2 word3")
123-
mockGetMnemonicNetwork.mockResolvedValue("mainnet")
124134

125135
const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper })
126136

@@ -131,9 +141,8 @@ describe("SelfCustodialWalletProvider", () => {
131141
expect(mockInitSdk).not.toHaveBeenCalled()
132142
})
133143

134-
it("allows null stored network (legacy wallets)", async () => {
144+
it("initializes SDK when network validation passes", async () => {
135145
mockGetMnemonic.mockResolvedValue("word1 word2 word3")
136-
mockGetMnemonicNetwork.mockResolvedValue(null)
137146
mockInitSdk.mockRejectedValue(new Error("SDK not available"))
138147

139148
renderHook(() => useSelfCustodialWallet(), { wrapper })
@@ -348,4 +357,99 @@ describe("SelfCustodialWalletProvider", () => {
348357

349358
expect(mockDisconnectSdk).toHaveBeenCalledWith(mockSdk)
350359
})
360+
361+
it("updates lastReceivedPaymentId when a PaymentSucceeded event carries a payment id", async () => {
362+
const mockSnapshot = jest.requireMock(
363+
"@app/self-custodial/providers/wallet-snapshot",
364+
).getSelfCustodialWalletSnapshot
365+
mockSnapshot.mockResolvedValue({ wallets: [], hasMore: false })
366+
367+
let capturedListener: (event: { tag: string; inner?: unknown }) => Promise<void>
368+
mockAddSdkEventListener.mockImplementation(
369+
(
370+
_sdk: unknown,
371+
onEvent: (event: { tag: string; inner?: unknown }) => Promise<void>,
372+
) => {
373+
capturedListener = onEvent
374+
return Promise.resolve("id")
375+
},
376+
)
377+
378+
mockGetMnemonic.mockResolvedValue("word1 word2 word3")
379+
mockInitSdk.mockResolvedValue({})
380+
381+
const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper })
382+
383+
await waitFor(() => {
384+
expect(mockAddSdkEventListener).toHaveBeenCalled()
385+
})
386+
387+
await act(async () => {
388+
await capturedListener!({
389+
tag: "PaymentSucceeded",
390+
inner: { payment: { id: "pay-new-42" } },
391+
})
392+
})
393+
394+
expect(result.current.lastReceivedPaymentId).toBe("pay-new-42")
395+
})
396+
397+
it("does not update lastReceivedPaymentId for non-payment refresh events", async () => {
398+
const mockSnapshot = jest.requireMock(
399+
"@app/self-custodial/providers/wallet-snapshot",
400+
).getSelfCustodialWalletSnapshot
401+
mockSnapshot.mockResolvedValue({ wallets: [], hasMore: false })
402+
403+
let capturedListener: (event: { tag: string }) => Promise<void>
404+
mockAddSdkEventListener.mockImplementation(
405+
(_sdk: unknown, onEvent: (event: { tag: string }) => Promise<void>) => {
406+
capturedListener = onEvent
407+
return Promise.resolve("id")
408+
},
409+
)
410+
411+
mockGetMnemonic.mockResolvedValue("word1 word2 word3")
412+
mockInitSdk.mockResolvedValue({})
413+
414+
const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper })
415+
416+
await waitFor(() => {
417+
expect(mockAddSdkEventListener).toHaveBeenCalled()
418+
})
419+
420+
await act(async () => {
421+
await capturedListener!({ tag: "Synced" })
422+
})
423+
424+
expect(result.current.lastReceivedPaymentId).toBeNull()
425+
})
426+
427+
it("loadMore calls loadMoreTransactions and appends via appendTransactions", async () => {
428+
const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot")
429+
snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue({
430+
wallets: [],
431+
hasMore: true,
432+
})
433+
snapshot.loadMoreTransactions.mockResolvedValue({
434+
transactions: [{ id: "tx-new" }],
435+
hasMore: false,
436+
})
437+
438+
mockGetMnemonic.mockResolvedValue("word1 word2 word3")
439+
mockInitSdk.mockResolvedValue({})
440+
441+
const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper })
442+
443+
await waitFor(() => {
444+
expect(result.current.hasMoreTransactions).toBe(true)
445+
})
446+
447+
await act(async () => {
448+
await result.current.loadMore()
449+
})
450+
451+
expect(snapshot.loadMoreTransactions).toHaveBeenCalled()
452+
expect(snapshot.appendTransactions).toHaveBeenCalled()
453+
expect(result.current.hasMoreTransactions).toBe(false)
454+
})
351455
})

__tests__/self-custodial/providers/wallet-snapshot.spec.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { WalletCurrency } from "@app/graphql/generated"
2+
import { toWalletMoneyAmount } from "@app/types/amounts"
3+
import type { NormalizedTransaction } from "@app/types/transaction.types"
4+
import { AccountType, toWalletId, type WalletState } from "@app/types/wallet.types"
25

3-
import { getSelfCustodialWalletSnapshot } from "@app/self-custodial/providers/wallet-snapshot"
6+
import {
7+
appendTransactions,
8+
getSelfCustodialWalletSnapshot,
9+
} from "@app/self-custodial/providers/wallet-snapshot"
410

511
jest.mock("@app/self-custodial/config", () => ({
612
SparkToken: { Label: "USDB", Ticker: "USDB" },
@@ -82,3 +88,68 @@ describe("getSelfCustodialWalletSnapshot", () => {
8288
expect(wallets[0].transactions.length).toBeGreaterThanOrEqual(0)
8389
})
8490
})
91+
92+
const buildTx = (id: string, currency: WalletCurrency): NormalizedTransaction => ({
93+
id,
94+
amount: toWalletMoneyAmount(1, currency),
95+
direction: "receive",
96+
status: "completed",
97+
timestamp: 1,
98+
paymentType: "lightning",
99+
sourceAccountType: AccountType.SelfCustodial,
100+
})
101+
102+
const buildWallet = (
103+
currency: WalletCurrency,
104+
transactions: NormalizedTransaction[] = [],
105+
): WalletState => ({
106+
id: toWalletId(`wallet-${currency}`),
107+
walletCurrency: currency,
108+
balance: toWalletMoneyAmount(0, currency),
109+
transactions,
110+
})
111+
112+
describe("appendTransactions", () => {
113+
it("appends BTC txs only to BTC wallet and USD txs only to USD wallet", () => {
114+
const wallets = [buildWallet(WalletCurrency.Btc), buildWallet(WalletCurrency.Usd)]
115+
const newTxs = [
116+
buildTx("btc-1", WalletCurrency.Btc),
117+
buildTx("usd-1", WalletCurrency.Usd),
118+
]
119+
120+
const result = appendTransactions(wallets, newTxs)
121+
122+
expect(result[0].transactions.map((t) => t.id)).toEqual(["btc-1"])
123+
expect(result[1].transactions.map((t) => t.id)).toEqual(["usd-1"])
124+
})
125+
126+
it("preserves existing transactions and appends new ones at the end", () => {
127+
const existing = buildTx("existing-btc", WalletCurrency.Btc)
128+
const wallets = [buildWallet(WalletCurrency.Btc, [existing])]
129+
const newTxs = [buildTx("new-btc", WalletCurrency.Btc)]
130+
131+
const result = appendTransactions(wallets, newTxs)
132+
133+
expect(result[0].transactions.map((t) => t.id)).toEqual(["existing-btc", "new-btc"])
134+
})
135+
136+
it("returns a new array without mutating the input wallets", () => {
137+
const wallets = [buildWallet(WalletCurrency.Btc)]
138+
const newTxs = [buildTx("btc-1", WalletCurrency.Btc)]
139+
140+
const result = appendTransactions(wallets, newTxs)
141+
142+
expect(result).not.toBe(wallets)
143+
expect(result[0]).not.toBe(wallets[0])
144+
expect(wallets[0].transactions).toHaveLength(0)
145+
})
146+
147+
it("handles empty newTxs without changing transaction lists", () => {
148+
const existing = buildTx("existing-btc", WalletCurrency.Btc)
149+
const wallets = [buildWallet(WalletCurrency.Btc, [existing])]
150+
151+
const result = appendTransactions(wallets, [])
152+
153+
expect(result[0].transactions).toEqual([existing])
154+
})
155+
})

0 commit comments

Comments
 (0)