Skip to content
Merged
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
25 changes: 21 additions & 4 deletions src/app/cash-wallet-cutover/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type PreparePrimaryCashWalletCutoverResult = {
report: CashWalletCutoverPreflightReport
plannedMigrations: PrimaryCashWalletMigrationPlan[]
migrations: CashWalletMigration[]
// Cohort accountIds that were requested but not found among unlocked
// accounts — surfaced so the operator notices typos/locked/missing ids.
cohortNotFound?: AccountId[]
}

export const preparePrimaryCashWalletCutover = async ({
Expand All @@ -24,18 +27,32 @@ export const preparePrimaryCashWalletCutover = async ({
accountsRepo,
walletsRepo,
migrationsRepo,
accountIds,
}: {
cutoverVersion: number
runId: string
accountsRepo: Pick<IAccountsRepository, "listUnlockedAccounts">
walletsRepo: Pick<IWalletsRepository, "listByAccountId">
migrationsRepo: CashWalletMigrationRecordsRepository
// Phased cutover (runbook: internal accounts first, then beta cohort).
// When set, only these accounts are prepared; everything else is left
// untouched. When omitted, the whole unlocked population is prepared.
accountIds?: AccountId[]
}): Promise<PreparePrimaryCashWalletCutoverResult | RepositoryError> => {
const discoveries = await discoverCashWalletCutoverAccounts({
const allDiscoveries = await discoverCashWalletCutoverAccounts({
accountsRepo,
walletsRepo,
})
if (discoveries instanceof Error) return discoveries
if (allDiscoveries instanceof Error) return allDiscoveries

let discoveries = allDiscoveries
let cohortNotFound: AccountId[] | undefined
if (accountIds !== undefined) {
const requested = new Set<string>(accountIds)
discoveries = allDiscoveries.filter((d) => requested.has(d.accountId))
const found = new Set<string>(discoveries.map((d) => d.accountId))
cohortNotFound = accountIds.filter((id) => !found.has(id))
}

const report = buildCashWalletCutoverPreflightReport({
cutoverVersion,
Expand All @@ -44,7 +61,7 @@ export const preparePrimaryCashWalletCutover = async ({
})

if (!report.canStart) {
return { report, plannedMigrations: [], migrations: [] }
return { report, plannedMigrations: [], migrations: [], cohortNotFound }
}

const plannedMigrations = buildPrimaryCashWalletMigrationPlan({
Expand All @@ -59,5 +76,5 @@ export const preparePrimaryCashWalletCutover = async ({
})
if (migrations instanceof Error) return migrations

return { report, plannedMigrations, migrations }
return { report, plannedMigrations, migrations, cohortNotFound }
}
11 changes: 11 additions & 0 deletions src/scripts/cash-wallet-cutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ const args = yargs(hideBin(process.argv))
.option("max-provision-attempts", { type: "number", default: 5 })
.option("dry-run", { type: "boolean", default: false })
.option("account-id", { type: "string" })
.option("account-ids", {
type: "string",
describe: "comma-separated accountIds — prepare only this cohort (phased cutover)",
})
.option("reason", { type: "string", default: "" })
.option("lock-stale-seconds", { type: "number", default: 300 })
.option("configPath", { type: "string", demandOption: true })
Expand Down Expand Up @@ -115,12 +119,19 @@ const run = async () => {
}

case "prepare": {
const cohort = args["account-ids"]
? (args["account-ids"]
.split(",")
.map((s) => s.trim())
.filter(Boolean) as AccountId[])
: undefined
const result = await CashWalletCutover.preparePrimaryCashWalletCutover({
cutoverVersion,
runId,
accountsRepo: AccountsRepository(),
walletsRepo: WalletsRepository(),
migrationsRepo: repository,
accountIds: cohort,
})
if (result instanceof Error) throw result
toJson(result)
Expand Down
84 changes: 84 additions & 0 deletions test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const discoverMock = jest.fn()
jest.mock("@app/cash-wallet-cutover/discovery", () => ({
discoverCashWalletCutoverAccounts: (...args: unknown[]) => discoverMock(...args),
}))

const upsertMock = jest.fn()
jest.mock("@app/cash-wallet-cutover/migration-records", () => ({
upsertPrimaryCashWalletMigrationRecords: (args: { plans: unknown[] }) => {
upsertMock(args)
// echo plans back as "migrations" so prepare returns cleanly
return Promise.resolve(args.plans)
},
}))

import { preparePrimaryCashWalletCutover } from "@app/cash-wallet-cutover/prepare"

const legacyDefault = (id: string) => ({
status: "legacy_default" as const,
accountId: id as AccountId,
legacyUsdWalletId: `${id}-usd` as WalletId,
destinationUsdtWalletId: `${id}-usdt` as WalletId,
previousDefaultWalletId: `${id}-usd` as WalletId,
})

const repos = {
accountsRepo: {} as never,
walletsRepo: {} as never,
migrationsRepo: {} as never,
}

describe("preparePrimaryCashWalletCutover — cohort filter (phased cutover)", () => {
beforeEach(() => {
jest.clearAllMocks()
discoverMock.mockResolvedValue([
legacyDefault("acc-1"),
legacyDefault("acc-2"),
legacyDefault("acc-3"),
])
})

it("prepares the whole population when no cohort is given", async () => {
const result = await preparePrimaryCashWalletCutover({
cutoverVersion: 1,
runId: "run-1",
...repos,
})
if (result instanceof Error) throw result
expect(result.plannedMigrations.map((m) => m.accountId).sort()).toEqual([
"acc-1",
"acc-2",
"acc-3",
])
expect(result.cohortNotFound).toBeUndefined()
})

it("prepares only the requested cohort, leaving the rest untouched", async () => {
const result = await preparePrimaryCashWalletCutover({
cutoverVersion: 1,
runId: "run-1",
accountIds: ["acc-1", "acc-3"] as AccountId[],
...repos,
})
if (result instanceof Error) throw result
expect(result.plannedMigrations.map((m) => m.accountId).sort()).toEqual([
"acc-1",
"acc-3",
])
expect(result.cohortNotFound).toEqual([])
})

it("reports requested ids that were not found among unlocked accounts", async () => {
const result = await preparePrimaryCashWalletCutover({
cutoverVersion: 1,
runId: "run-1",
accountIds: ["acc-2", "acc-missing"] as AccountId[],
...repos,
})
if (result instanceof Error) throw result
expect(result.plannedMigrations.map((m) => m.accountId)).toEqual(["acc-2"])
expect(result.cohortNotFound).toEqual(["acc-missing"])
// the preflight report is scoped to the cohort, not the full population
expect(result.report.totalAccounts).toBe(1)
})
})
Loading