Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
45d6934
fix(settings): skip graphql queries when unauthenticated to prevent f…
esaugomez31 Apr 22, 2026
2487ff4
fix(backup-nudge): drop dismissal-load gate from settings banner
esaugomez31 Apr 22, 2026
c474a2d
fix(account-type-selection): apply grey6 background to selected card
esaugomez31 Apr 23, 2026
e1663ae
fix(spark-onboarding): use success color for backup/restore method icons
esaugomez31 Apr 23, 2026
e4a3f28
fix(spark-cloud-backup): use warning color for Important InfoBanner icon
esaugomez31 Apr 23, 2026
26c3ced
fix(spark-cloud-backup): use success color for cloud hero icon
esaugomez31 Apr 23, 2026
73c77e0
fix(spark-cloud-backup): show password errors only on blur
esaugomez31 Apr 23, 2026
0efc01d
fix(self-custodial): strip `lightning:` prefix from invoice display
esaugomez31 Apr 23, 2026
4adde38
fix(self-custodial): use snapshot-first refresh and abortable status …
esaugomez31 Apr 26, 2026
0bbda27
feat(spark-restore): auto-advance, paste detection, and clearer error…
esaugomez31 Apr 26, 2026
ad57cf5
feat(spark-restore): mark wallet as backed up after phrase restore
esaugomez31 Apr 26, 2026
06d61d2
feat(spark-manual-backup): focus advance, migration gate, re-backup r…
esaugomez31 Apr 26, 2026
77090a5
fix(backup-nudge): trigger thresholds based on combined balance
esaugomez31 Apr 26, 2026
38e5d0f
fix(home-screen): hide backup nudge modal when home tab is not focused
esaugomez31 Apr 26, 2026
ee470ee
feat(settings): split recovery method into its own group and refresh …
esaugomez31 Apr 26, 2026
6eef20b
test(self-custodial): align wallet-provider spec with branch's keysto…
esaugomez31 May 9, 2026
d2d1019
fix(spark-onboarding): gate backup auto-navigate on migration checkpo…
esaugomez31 May 9, 2026
e8e577a
fix(self-custodial): time-bound wallet snapshot to recover from SDK h…
esaugomez31 May 9, 2026
866bc83
fix(self-custodial): time-bound getOnlineState recovery probe to matc…
esaugomez31 May 9, 2026
51e0f87
fix(self-custodial): break out of Loading when connectivity probe sta…
esaugomez31 May 9, 2026
07f85a3
fix(self-custodial): drop stale snapshot writes against a replaced SD…
esaugomez31 May 9, 2026
5d20270
fix(spark-onboarding): re-arm backup auto-advance latch when user de-…
esaugomez31 May 9, 2026
6df414a
fix(settings): move View backup phrase under Security & Privacy and d…
esaugomez31 May 9, 2026
2035155
test(backup-nudge): exercise combined BTC+USD balance crossing the nu…
esaugomez31 May 9, 2026
1e15986
test(self-custodial): assert bridge forwards AbortSignal to the SDK g…
esaugomez31 May 9, 2026
2f271ec
test(i18n): assert backup/restore keys exist across all locales
esaugomez31 May 9, 2026
bd7c182
test(home-screen): assert backup nudge modal focus gating
esaugomez31 May 9, 2026
dacae07
test(spark-onboarding): cover restore phrase screen states
esaugomez31 May 9, 2026
cc2686f
test(settings-screen): assert auth-gated unread-notifications query
esaugomez31 May 9, 2026
0fea8ea
test(home-screen): move backup nudge focus gating into the real HomeS…
esaugomez31 May 9, 2026
683dde0
fix(self-custodial): record receive adapter errors to crashlytics ins…
esaugomez31 May 9, 2026
e0cfca9
fix(secure-storage): record deleteMnemonic failures to crashlytics so…
esaugomez31 May 9, 2026
a73d77e
fix(self-custodial): record fee-quote failures via recordError so the…
esaugomez31 May 9, 2026
f3ec94e
docs(self-custodial): note why snapshot success skips a second servic…
esaugomez31 May 9, 2026
08a1582
fix(self-custodial): add amount + currency breadcrumb before recordin…
esaugomez31 May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions __tests__/components/mnemonic-word-input.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from "react"
import React, { createRef } from "react"
import { TextInput } from "react-native"
import { render, fireEvent } from "@testing-library/react-native"

import { MnemonicWordInput } from "@app/components/mnemonic-word-input"
import {
MnemonicWordInput,
MnemonicWordInputHandle,
} from "@app/components/mnemonic-word-input"

jest.mock("@rn-vui/themed", () => {
const colors = {
Expand Down Expand Up @@ -81,4 +85,16 @@ describe("MnemonicWordInput", () => {

expect(onFocus).toHaveBeenCalled()
})

it("forwards focus through ref to underlying TextInput", () => {
const ref = createRef<MnemonicWordInputHandle>()
const focusSpy = jest.spyOn(TextInput.prototype, "focus")

render(<MnemonicWordInput ref={ref} {...defaultProps} />)

ref.current?.focus()

expect(focusSpy).toHaveBeenCalled()
focusSpy.mockRestore()
})
})
12 changes: 12 additions & 0 deletions __tests__/components/password-input.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,16 @@ describe("PasswordInput", () => {

expect(queryByText("Too short")).toBeNull()
})

it("calls onBlur when the input loses focus", () => {
const onBlur = jest.fn()
const { getByPlaceholderText } = render(
<ContextForScreen>
<PasswordInput {...defaultProps} onBlur={onBlur} />
</ContextForScreen>,
)

fireEvent(getByPlaceholderText("Enter password"), "blur")
expect(onBlur).toHaveBeenCalledTimes(1)
})
})
73 changes: 68 additions & 5 deletions __tests__/hooks/use-backup-nudge-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ jest.mock("@app/config/feature-flags-context", () => ({
useRemoteConfig: () => mockRemoteConfig(),
}))

const SATS_PER_USD_CENT = 10

jest.mock("@app/components/balance-header/use-total-balance", () => ({
useTotalBalance: (wallets: Array<{ walletCurrency: string; balance: number }>) => ({
satsBalance: wallets.reduce((sum, w) => {
if (w.walletCurrency === "BTC") return sum + w.balance
if (w.walletCurrency === "USD") return sum + w.balance * 10
return sum
}, 0),
}),
}))

jest.mock("@react-native-async-storage/async-storage", () => ({
getItem: (...args: string[]) => mockGetItem(...args),
setItem: (...args: string[]) => mockSetItem(...args),
Expand All @@ -30,11 +42,11 @@ const defaultBackupState = { backupState: { status: "none", method: null } }
const completedBackupState = { backupState: { status: "completed", method: "manual" } }
const selfCustodialWallet = {
accountType: "self-custodial",
wallets: [{ walletCurrency: "BTC", balance: { amount: 3000 } }],
wallets: [{ id: "btc-1", walletCurrency: "BTC", balance: { amount: 3000 } }],
}
const custodialWallet = {
accountType: "custodial",
wallets: [{ walletCurrency: "BTC", balance: { amount: 50000 } }],
wallets: [{ id: "btc-1", walletCurrency: "BTC", balance: { amount: 50000 } }],
}
const defaultConfig = {
backupNudgeBannerThreshold: 2100,
Expand All @@ -51,14 +63,21 @@ describe("useBackupNudgeState", () => {
mockSetItem.mockResolvedValue(undefined)
})

it("returns false for all flags before loaded resolves", () => {
it("hides banner and modal while dismissal-load is pending", () => {
mockGetItem.mockReturnValue(new Promise(() => {}))

const { result } = renderHook(() => useBackupNudgeState())

expect(result.current.shouldShowBanner).toBe(false)
expect(result.current.shouldShowModal).toBe(false)
expect(result.current.shouldShowSettingsBanner).toBe(false)
})

it("shows settings banner without waiting for dismissal-load", () => {
mockGetItem.mockReturnValue(new Promise(() => {}))

const { result } = renderHook(() => useBackupNudgeState())

expect(result.current.shouldShowSettingsBanner).toBe(true)
})

it("shows banner when balance >= banner threshold and not backed up", async () => {
Expand All @@ -73,7 +92,7 @@ describe("useBackupNudgeState", () => {
it("shows modal when balance >= modal threshold", async () => {
mockActiveWallet.mockReturnValue({
accountType: "self-custodial",
wallets: [{ walletCurrency: "BTC", balance: { amount: 22000 } }],
wallets: [{ id: "btc-1", walletCurrency: "BTC", balance: { amount: 22000 } }],
})

const { result } = renderHook(() => useBackupNudgeState())
Expand Down Expand Up @@ -147,4 +166,48 @@ describe("useBackupNudgeState", () => {

expect(result.current.shouldShowBanner).toBe(true)
})

it("triggers banner when USD weight pushes combined balance over the threshold", async () => {
const btcAmount = 1_000
const usdCentsAmount = 200
const combinedSats = btcAmount + usdCentsAmount * SATS_PER_USD_CENT
expect(btcAmount).toBeLessThan(defaultConfig.backupNudgeBannerThreshold)
expect(combinedSats).toBeGreaterThanOrEqual(defaultConfig.backupNudgeBannerThreshold)

mockActiveWallet.mockReturnValue({
accountType: "self-custodial",
wallets: [
{ id: "btc-1", walletCurrency: "BTC", balance: { amount: btcAmount } },
{ id: "usd-1", walletCurrency: "USD", balance: { amount: usdCentsAmount } },
],
})

const { result } = renderHook(() => useBackupNudgeState())

await act(async () => {})

expect(result.current.shouldShowBanner).toBe(true)
})

it("triggers modal when USD weight pushes combined balance over the modal threshold", async () => {
const btcAmount = 1_000
const usdCentsAmount = 2_200
const combinedSats = btcAmount + usdCentsAmount * SATS_PER_USD_CENT
expect(btcAmount).toBeLessThan(defaultConfig.backupNudgeModalThreshold)
expect(combinedSats).toBeGreaterThanOrEqual(defaultConfig.backupNudgeModalThreshold)

mockActiveWallet.mockReturnValue({
accountType: "self-custodial",
wallets: [
{ id: "btc-1", walletCurrency: "BTC", balance: { amount: btcAmount } },
{ id: "usd-1", walletCurrency: "USD", balance: { amount: usdCentsAmount } },
],
})

const { result } = renderHook(() => useBackupNudgeState())

await act(async () => {})

expect(result.current.shouldShowModal).toBe(true)
})
})
86 changes: 81 additions & 5 deletions __tests__/hooks/use-bip39-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,29 @@ import { renderHook, act } from "@testing-library/react-native"
import { useBip39Input } from "@app/hooks/use-bip39-input"

jest.mock("@app/utils/bip39-wordlist", () => ({
BIP39_WORDLIST_EN: ["abandon", "ability", "able", "about", "above", "absent"],
getBip39Suggestions: (prefix: string) =>
["abandon", "ability", "able", "about", "above", "absent"].filter((w) =>
w.startsWith(prefix),
),
BIP39_WORDLIST_EN: [
"abandon",
"ability",
"able",
"about",
"above",
"absent",
"run",
"runway",
],
getBip39Suggestions: (prefix: string, options?: { maxResults?: number }) => {
const all = [
"abandon",
"ability",
"able",
"about",
"above",
"absent",
"run",
"runway",
].filter((w) => w.startsWith(prefix))
return options?.maxResults ? all.slice(0, options.maxResults) : all
},
splitWords: (text: string) => text.trim().toLowerCase().split(/\s+/),
}))

Expand Down Expand Up @@ -177,4 +195,62 @@ describe("useBip39Input", () => {
expect(result.current.words[5]).toBe("absent")
expect(result.current.activeIndex).toBe(5)
})

it("requests focus on next index when input is a prefix-unique BIP39 word", () => {
const { result } = renderHook(() => useBip39Input({ wordCount: 12 }))

act(() => {
result.current.updateWord(0, "abandon")
})

expect(result.current.focusRequest).toBe(1)
})

it("does not advance when input is BIP39 word but prefix has multiple matches", () => {
const { result } = renderHook(() => useBip39Input({ wordCount: 12 }))

act(() => {
result.current.updateWord(0, "run")
})

expect(result.current.focusRequest).toBeNull()
})

it("does not advance when input is not a BIP39 word", () => {
const { result } = renderHook(() => useBip39Input({ wordCount: 12 }))

act(() => {
result.current.updateWord(0, "ru")
})

expect(result.current.focusRequest).toBeNull()
})

it("does not advance past last index in step", () => {
const { result } = renderHook(() =>
useBip39Input({ wordCount: 12, wordsPerStep: 6, step: 1 }),
)

act(() => {
result.current.updateWord(5, "abandon")
})

expect(result.current.focusRequest).toBeNull()
})

it("clearFocusRequest resets focusRequest", () => {
const { result } = renderHook(() => useBip39Input({ wordCount: 12 }))

act(() => {
result.current.updateWord(0, "abandon")
})

expect(result.current.focusRequest).toBe(1)

act(() => {
result.current.clearFocusRequest()
})

expect(result.current.focusRequest).toBeNull()
})
})
64 changes: 64 additions & 0 deletions __tests__/i18n/locale-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { loadLocale } from "@app/i18n/i18n-util.sync"
import { i18nObject } from "@app/i18n/i18n-util"
import type { Locales } from "@app/i18n/i18n-types"

const LOCALES: ReadonlyArray<Locales> = [
"af",
"ar",
"ca",
"cs",
"da",
"de",
"el",
"en",
"es",
"fr",
"hr",
"hu",
"hy",
"it",
"ja",
"lg",
"ms",
"nl",
"pt",
"qu",
"ro",
"sk",
"sr",
"sw",
"th",
"tr",
"vi",
]

describe("locale parity for backup/restore i18n keys", () => {
LOCALES.forEach((locale) => {
describe(`locale: ${locale}`, () => {
beforeAll(() => {
loadLocale(locale)
})

it("exposes a non-empty SettingsScreen.recoveryMethod string", () => {
const LL = i18nObject(locale)
const value = LL.SettingsScreen.recoveryMethod()
expect(typeof value).toBe("string")
expect(value.length).toBeGreaterThan(0)
})

it("exposes a non-empty RestoreScreen.invalidMnemonic string", () => {
const LL = i18nObject(locale)
const value = LL.RestoreScreen.invalidMnemonic()
expect(typeof value).toBe("string")
expect(value.length).toBeGreaterThan(0)
})

it("exposes a non-empty BackupScreen.ManualBackup.Phrase.headerTitle string", () => {
const LL = i18nObject(locale)
const value = LL.BackupScreen.ManualBackup.Phrase.headerTitle()
expect(typeof value).toBe("string")
expect(value.length).toBeGreaterThan(0)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,28 @@ jest.mock("@app/i18n/i18n-react", () => ({
}),
}))

const mockCardDefaultBg = "#1d1d1d"
const mockCardSelectedBg = "#2B2B2B"
const mockPrimary = "#fc5805"

jest.mock("@rn-vui/themed", () => ({
makeStyles:
(fn: (args: { colors: Record<string, string> }) => Record<string, object>) => () =>
fn({
colors: {
primary: "#000",
primary: "#fc5805",
grey2: "#949494",
grey3: "#999",
grey5: "#eee",
grey5: "#1d1d1d",
grey6: "#2B2B2B",
black: "#000",
},
}),
Text: ({ children, ...props }: { children: React.ReactNode }) =>
React.createElement("Text", props, children),
useTheme: () => ({ theme: { colors: { primary: "#000", grey5: "#eee" } } }),
useTheme: () => ({
theme: { colors: { primary: "#fc5805", grey5: "#1d1d1d", grey6: "#2B2B2B" } },
}),
}))

jest.mock("@app/components/atomic/galoy-icon", () => ({
Expand Down Expand Up @@ -166,4 +173,33 @@ describe("AccountTypeSelectionScreen", () => {

expect(mockNavigate).not.toHaveBeenCalled()
})

it("uses grey5 as default card background and grey6 when selected", () => {
const { getByTestId } = render(<AccountTypeSelectionScreen />)

const custodialCard = getByTestId("custodial-option")
const selfCustodialCard = getByTestId("self-custodial-option")

expect(custodialCard.props.style).toEqual(
expect.arrayContaining([
expect.objectContaining({ backgroundColor: mockCardDefaultBg }),
]),
)
expect(selfCustodialCard.props.style).toEqual(
expect.arrayContaining([
expect.objectContaining({ backgroundColor: mockCardDefaultBg }),
]),
)

fireEvent.press(custodialCard)

expect(custodialCard.props.style).toEqual(
expect.arrayContaining([
expect.objectContaining({
backgroundColor: mockCardSelectedBg,
borderColor: mockPrimary,
}),
]),
)
})
})
Loading
Loading