Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 50 additions & 0 deletions test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
destinationShortfallUsdtMicros,
feeUsdCentsToUsdtMicros,
usdCentsToUsdtMicros,
usdtMicrosToUsdCentsCeil,
} from "@app/cash-wallet-cutover/amount-conversion"

describe("cash wallet cutover amount conversion", () => {
it("converts USD cents to USDT micros exactly", () => {
expect(usdCentsToUsdtMicros("0")).toBe("0")
expect(usdCentsToUsdtMicros("1")).toBe("10000")
expect(usdCentsToUsdtMicros("100")).toBe("1000000")
expect(usdCentsToUsdtMicros("123456789")).toBe("1234567890000")
})

it("converts fee USD cents to USDT micros exactly", () => {
expect(feeUsdCentsToUsdtMicros("7")).toBe("70000")
})

it("rounds USDT micros up to USD cents for fee audit fields", () => {
expect(usdtMicrosToUsdCentsCeil("0")).toBe("0")
expect(usdtMicrosToUsdCentsCeil("1")).toBe("1")
expect(usdtMicrosToUsdCentsCeil("10000")).toBe("1")
expect(usdtMicrosToUsdCentsCeil("10001")).toBe("2")
})

it("computes destination USDT shortfall from the observed balance delta", () => {
expect(
destinationShortfallUsdtMicros({
targetUsdtMicros: "10000000",
startingUsdtMicros: "5000000",
currentUsdtMicros: "14930000",
}),
).toBe("70000")
expect(
destinationShortfallUsdtMicros({
targetUsdtMicros: "10000000",
startingUsdtMicros: "5000000",
currentUsdtMicros: "15000000",
}),
).toBe("0")
})

it("rejects invalid or fractional cent inputs", () => {
expect(usdCentsToUsdtMicros("1.5")).toBeInstanceOf(Error)
expect(usdCentsToUsdtMicros("abc")).toBeInstanceOf(Error)
expect(usdCentsToUsdtMicros("-1")).toBeInstanceOf(Error)
expect(usdtMicrosToUsdCentsCeil("1.5")).toBeInstanceOf(Error)
})
})
46 changes: 46 additions & 0 deletions test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
CASH_WALLET_USDT_CLIENT_CAPABILITY,
parseCashWalletClientCapabilities,
} from "@app/cash-wallet-cutover/client-capability"

describe("cash wallet client capability parser", () => {
it("defaults missing headers to legacy compatibility", () => {
expect(parseCashWalletClientCapabilities({})).toEqual({
cashWalletPresentation: "legacy_compat",
hasUsdtCashWalletSupport: false,
})
})

it("treats unknown capabilities as legacy compatibility", () => {
expect(
parseCashWalletClientCapabilities({
"x-flash-client-capabilities": "contacts-v2",
}),
).toEqual({
cashWalletPresentation: "legacy_compat",
hasUsdtCashWalletSupport: false,
})
})

it("detects the USDT Cash Wallet capability", () => {
expect(
parseCashWalletClientCapabilities({
"x-flash-client-capabilities": `contacts-v2, ${CASH_WALLET_USDT_CLIENT_CAPABILITY}`,
}),
).toEqual({
cashWalletPresentation: "usdt",
hasUsdtCashWalletSupport: true,
})
})

it("accepts native client connection-param casing", () => {
expect(
parseCashWalletClientCapabilities({
"X-Flash-Client-Capabilities": CASH_WALLET_USDT_CLIENT_CAPABILITY,
}),
).toEqual({
cashWalletPresentation: "usdt",
hasUsdtCashWalletSupport: true,
})
})
})
154 changes: 154 additions & 0 deletions test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
CashWalletCutoverInProgressError,
CashWalletMigrationFailedError,
evaluateCashWalletCutoverGuard,
evaluateCashWalletCutoverPresentation,
} from "@app/cash-wallet-cutover/guard"

const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({
state,
cutoverVersion: 2,
runId: "run-2",
updatedAt: new Date("2026-05-19T00:00:00Z"),
})

const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({
id: "migration-id",
accountId: "account-id" as AccountId,
legacyUsdWalletId: "usd-wallet-id" as WalletId,
destinationUsdtWalletId: "usdt-wallet-id" as WalletId,
cutoverVersion: 2,
runId: "run-2",
status,
idempotencyKey: "run-2:account-id",
attempts: 0,
updatedAt: new Date("2026-05-19T00:00:00Z"),
})

const legacyClient = {
cashWalletPresentation: "legacy_compat" as const,
hasUsdtCashWalletSupport: false,
}

const usdtClient = {
cashWalletPresentation: "usdt" as const,
hasUsdtCashWalletSupport: true,
}

describe("cash wallet cutover guard", () => {
it("allows legacy route before cutover starts", () => {
expect(evaluateCashWalletCutoverGuard({ cutover: config("pre") })).toEqual({
route: "legacy_usd",
})
})

it("allows legacy route during cutover before this account starts", () => {
expect(evaluateCashWalletCutoverGuard({ cutover: config("in_progress") })).toEqual({
route: "legacy_usd",
})
expect(
evaluateCashWalletCutoverGuard({
cutover: config("in_progress"),
migration: migration("not_started"),
}),
).toEqual({ route: "legacy_usd" })
})

it("rejects writes while this account is actively migrating", () => {
for (const status of [
"balance_read",
"balance_move_sending",
"fee_reimbursement_sending",
] as const) {
expect(
evaluateCashWalletCutoverGuard({
cutover: config("in_progress"),
migration: migration(status),
}),
).toBeInstanceOf(CashWalletCutoverInProgressError)
}
})

it("routes completed accounts to USDT during cutover", () => {
expect(
evaluateCashWalletCutoverGuard({
cutover: config("in_progress"),
migration: migration("complete"),
}),
).toEqual({ route: "usdt" })
})

it("rejects failed and manual-review migrations", () => {
for (const status of ["failed", "requires_operator_review"] as const) {
expect(
evaluateCashWalletCutoverGuard({
cutover: config("in_progress"),
migration: migration(status),
}),
).toBeInstanceOf(CashWalletMigrationFailedError)
}
})

it("routes all accounts to USDT after global completion", () => {
expect(evaluateCashWalletCutoverGuard({ cutover: config("complete") })).toEqual({
route: "usdt",
})
})
})

describe("cash wallet cutover presentation", () => {
it("presents legacy USD before cutover starts", () => {
expect(
evaluateCashWalletCutoverPresentation({
cutover: config("pre"),
client: usdtClient,
}),
).toEqual({
presentation: "legacy_usd",
})
})

it("presents completed accounts as legacy-compatible for old clients", () => {
expect(
evaluateCashWalletCutoverPresentation({
cutover: config("in_progress"),
migration: migration("complete"),
client: legacyClient,
}),
).toEqual({
presentation: "legacy_usd_compat",
})
})

it("presents completed accounts as USDT for capable clients", () => {
expect(
evaluateCashWalletCutoverPresentation({
cutover: config("in_progress"),
migration: migration("complete"),
client: usdtClient,
}),
).toEqual({
presentation: "usdt",
})
})

it("uses client capability after global completion", () => {
expect(
evaluateCashWalletCutoverPresentation({
cutover: config("complete"),
client: legacyClient,
}),
).toEqual({
presentation: "legacy_usd_compat",
})

expect(
evaluateCashWalletCutoverPresentation({
cutover: config("complete"),
client: usdtClient,
}),
).toEqual({
presentation: "usdt",
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { RepositoryError } from "@domain/errors"

import { discoverCashWalletCutoverAccounts } from "@app/cash-wallet-cutover/discovery"

import { WalletCurrency } from "@domain/shared"
import { WalletType } from "@domain/wallets"

const account = (id: AccountId, defaultWalletId: WalletId): Account =>
({
id,
uuid: `${id}-uuid` as AccountUuid,
defaultWalletId,
}) as Account

const wallet = ({
id,
accountId,
currency,
}: {
id: WalletId
accountId: AccountId
currency: WalletCurrency
}): Wallet =>
({
id,
accountId,
type: WalletType.Checking,
currency,
onChainAddressIdentifiers: [],
onChainAddresses: () => [],
lnurlp: "lnurl" as Lnurl,
}) as Wallet

async function* unlockedAccounts(accounts: Account[]): AsyncGenerator<Account> {
for (const account of accounts) yield account
}

describe("cash wallet cutover account discovery collector", () => {
it("classifies every unlocked account with its wallets", async () => {
const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId)
const accountTwo = account("account-2" as AccountId, "account-2-usdt" as WalletId)
const walletsRepo = {
listByAccountId: jest.fn(async (accountId: AccountId) => [
wallet({
id: `${accountId}-usd` as WalletId,
accountId,
currency: WalletCurrency.Usd,
}),
wallet({
id: `${accountId}-usdt` as WalletId,
accountId,
currency: WalletCurrency.Usdt,
}),
]),
}

const result = await discoverCashWalletCutoverAccounts({
accountsRepo: {
listUnlockedAccounts: () => unlockedAccounts([accountOne, accountTwo]),
},
walletsRepo,
})

expect(result).toEqual([
expect.objectContaining({ accountId: "account-1", status: "legacy_default" }),
expect.objectContaining({ accountId: "account-2", status: "already_usdt" }),
])
expect(walletsRepo.listByAccountId).toHaveBeenCalledWith("account-1")
expect(walletsRepo.listByAccountId).toHaveBeenCalledWith("account-2")
})

it("returns repository errors without continuing discovery", async () => {
const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId)
const error = new RepositoryError("wallet lookup failed")
const walletsRepo = {
listByAccountId: jest.fn(async () => error),
}

const result = await discoverCashWalletCutoverAccounts({
accountsRepo: { listUnlockedAccounts: () => unlockedAccounts([accountOne]) },
walletsRepo,
})

expect(result).toBe(error)
})
})
Loading