diff --git a/src/app/cash-wallet-cutover/prepare.ts b/src/app/cash-wallet-cutover/prepare.ts index 35793b208..f4834de54 100644 --- a/src/app/cash-wallet-cutover/prepare.ts +++ b/src/app/cash-wallet-cutover/prepare.ts @@ -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 ({ @@ -24,18 +27,32 @@ export const preparePrimaryCashWalletCutover = async ({ accountsRepo, walletsRepo, migrationsRepo, + accountIds, }: { cutoverVersion: number runId: string accountsRepo: Pick walletsRepo: Pick 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 => { - 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(accountIds) + discoveries = allDiscoveries.filter((d) => requested.has(d.accountId)) + const found = new Set(discoveries.map((d) => d.accountId)) + cohortNotFound = accountIds.filter((id) => !found.has(id)) + } const report = buildCashWalletCutoverPreflightReport({ cutoverVersion, @@ -44,7 +61,7 @@ export const preparePrimaryCashWalletCutover = async ({ }) if (!report.canStart) { - return { report, plannedMigrations: [], migrations: [] } + return { report, plannedMigrations: [], migrations: [], cohortNotFound } } const plannedMigrations = buildPrimaryCashWalletMigrationPlan({ @@ -59,5 +76,5 @@ export const preparePrimaryCashWalletCutover = async ({ }) if (migrations instanceof Error) return migrations - return { report, plannedMigrations, migrations } + return { report, plannedMigrations, migrations, cohortNotFound } } diff --git a/src/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts index 0a45500f7..cae95d1a7 100644 --- a/src/scripts/cash-wallet-cutover.ts +++ b/src/scripts/cash-wallet-cutover.ts @@ -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 }) @@ -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) diff --git a/test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts b/test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts new file mode 100644 index 000000000..b8fe40e1e --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/prepare-cohort.spec.ts @@ -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) + }) +})