diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 40315059..8e8d58e4 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -43,6 +43,10 @@ import { runStatusCommand, } from "./codex-manager/commands/status.js"; import { runSwitchCommand } from "./codex-manager/commands/switch.js"; +import { + runVerifyFlaggedCommand, + type VerifyFlaggedCliOptions, +} from "./codex-manager/commands/verify-flagged.js"; import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, @@ -2257,12 +2261,6 @@ interface FixCliOptions { model: string; } -interface VerifyFlaggedCliOptions { - dryRun: boolean; - json: boolean; - restore: boolean; -} - type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; @@ -2552,13 +2550,6 @@ function summarizeFixReports(reports: FixAccountReport[]): { return { healthy, disabled, warnings, skipped }; } -interface VerifyFlaggedReport { - index: number; - label: string; - outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; - message: string; -} - function createEmptyAccountStorage(): AccountStorageV3 { const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { @@ -2718,300 +2709,28 @@ function upsertRecoveredFlaggedAccount( } async function runVerifyFlagged(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printVerifyFlaggedUsage(); - return 0; - } - - const parsedArgs = parseVerifyFlaggedArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printVerifyFlaggedUsage(); - return 1; - } - const options = parsedArgs.options; - - setStoragePath(null); - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: 0, - restored: 0, - healthyFlagged: 0, - stillFlagged: 0, - changed: false, - dryRun: options.dryRun, - restore: options.restore, - reports: [] as VerifyFlaggedReport[], - }, - null, - 2, - ), - ); - return 0; - } - console.log("No flagged accounts to check."); - return 0; - } - - let storageChanged = false; - let flaggedChanged = false; - const reports: VerifyFlaggedReport[] = []; - const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; - const now = Date.now(); - const refreshChecks: Array<{ - index: number; - flagged: FlaggedAccountMetadataV1; - label: string; - result: Awaited>; - }> = []; - - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = formatAccountLabel(flagged, i); - refreshChecks.push({ - index: i, - flagged, - label, - result: await queuedRefresh(flagged.refreshToken), - }); - } - - const applyRefreshChecks = (storage: AccountStorageV3): void => { - for (const check of refreshChecks) { - const { index: i, flagged, label, result } = check; - if (result.type === "success") { - if (!options.restore) { - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, - ); - const nextFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: - sanitizeEmail( - extractAccountEmail(result.access, result.idToken), - ) ?? flagged.email, - lastUsed: now, - lastError: undefined, - }; - nextFlaggedAccounts.push(nextFlagged); - if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "healthy-flagged", - message: - "session is healthy (left in flagged list due to --no-restore)", - }); - continue; - } - - const upsertResult = upsertRecoveredFlaggedAccount( - storage, - flagged, - result, - now, - ); - if (upsertResult.restored) { - storageChanged = storageChanged || upsertResult.changed; - flaggedChanged = true; - reports.push({ - index: i, - label, - outcome: "restored", - message: upsertResult.message, - }); - continue; - } - - const tokenAccountId = extractAccountId(result.access); - const nextIdentity = resolveStoredAccountIdentity( - flagged.accountId, - flagged.accountIdSource, - tokenAccountId, - ); - const updatedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - accountId: nextIdentity.accountId, - accountIdSource: nextIdentity.accountIdSource, - email: - sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? - flagged.email, - lastUsed: now, - lastError: upsertResult.message, - }; - nextFlaggedAccounts.push(updatedFlagged); - if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "restore-skipped", - message: upsertResult.message, - }); - continue; - } - - const detail = normalizeFailureDetail(result.message, result.reason); - const failedFlagged: FlaggedAccountMetadataV1 = { - ...flagged, - lastError: detail, - }; - nextFlaggedAccounts.push(failedFlagged); - if ((flagged.lastError ?? "") !== detail) { - flaggedChanged = true; - } - reports.push({ - index: i, - label, - outcome: "still-flagged", - message: detail, - }); - } - }; - - if (options.restore) { - if (options.dryRun) { - applyRefreshChecks((await loadAccounts()) ?? createEmptyAccountStorage()); - } else { - await withAccountAndFlaggedStorageTransaction( - async (loadedStorage, persist) => { - const nextStorage = loadedStorage - ? structuredClone(loadedStorage) - : createEmptyAccountStorage(); - applyRefreshChecks(nextStorage); - if (!storageChanged) { - return; - } - normalizeDoctorIndexes(nextStorage); - await persist(nextStorage, { - version: 1, - accounts: nextFlaggedAccounts, - }); - }, - ); - } - } else { - applyRefreshChecks(createEmptyAccountStorage()); - } - - const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter( - (report) => report.outcome === "restored", - ).length; - const healthyFlagged = reports.filter( - (report) => report.outcome === "healthy-flagged", - ).length; - const stillFlagged = reports.filter( - (report) => report.outcome === "still-flagged", - ).length; - const changed = storageChanged || flaggedChanged; - - if ( - !options.dryRun && - flaggedChanged && - (!options.restore || !storageChanged) - ) { - await saveFlaggedAccounts({ - version: 1, - accounts: nextFlaggedAccounts, - }); - } - - if (options.json) { - console.log( - JSON.stringify( - { - command: "verify-flagged", - total: flaggedStorage.accounts.length, - restored, - healthyFlagged, - stillFlagged, - remainingFlagged, - changed, - dryRun: options.dryRun, - restore: options.restore, - reports, - }, - null, - 2, - ), - ); - return 0; - } - - console.log( - stylePromptText( - `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, - "accent", - ), - ); - for (const report of reports) { - const tone = - report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" - ? "warning" - : "danger"; - const marker = - report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" - ? "!" - : "✗"; - console.log( - `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, - ); - } - console.log(""); - console.log( - formatResultSummary([ - { - text: `${restored} restored`, - tone: restored > 0 ? "success" : "muted", - }, - { - text: `${healthyFlagged} healthy (kept flagged)`, - tone: healthyFlagged > 0 ? "warning" : "muted", - }, - { - text: `${stillFlagged} still flagged`, - tone: stillFlagged > 0 ? "danger" : "muted", - }, - ]), - ); - if (options.dryRun) { - console.log( - stylePromptText("Preview only: no changes were saved.", "warning"), - ); - } else if (!changed) { - console.log(stylePromptText("No storage changes were needed.", "muted")); - } - - return 0; + return runVerifyFlaggedCommand(args, { + setStoragePath, + loadFlaggedAccounts, + loadAccounts, + queuedRefresh, + parseVerifyFlaggedArgs, + printVerifyFlaggedUsage, + createEmptyAccountStorage, + upsertRecoveredFlaggedAccount, + resolveStoredAccountIdentity, + extractAccountId, + extractAccountEmail, + sanitizeEmail, + normalizeFailureDetail, + withAccountAndFlaggedStorageTransaction, + normalizeDoctorIndexes, + saveFlaggedAccounts, + formatAccountLabel, + stylePromptText, + styleAccountDetailText, + formatResultSummary, + }); } async function runFix(args: string[]): Promise { diff --git a/lib/codex-manager/commands/verify-flagged.ts b/lib/codex-manager/commands/verify-flagged.ts new file mode 100644 index 00000000..734aba52 --- /dev/null +++ b/lib/codex-manager/commands/verify-flagged.ts @@ -0,0 +1,450 @@ +import type { + AccountStorageV3, + FlaggedAccountMetadataV1, +} from "../../storage.js"; +import type { TokenResult } from "../../types.js"; + +export interface VerifyFlaggedCliOptions { + dryRun: boolean; + json: boolean; + restore: boolean; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +export interface VerifyFlaggedReport { + index: number; + label: string; + outcome: "restored" | "healthy-flagged" | "still-flagged" | "restore-skipped"; + message: string; +} + +export interface VerifyFlaggedCommandDeps { + setStoragePath: (path: string | null) => void; + loadFlaggedAccounts: () => Promise<{ + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }>; + loadAccounts: () => Promise; + queuedRefresh: (refreshToken: string) => Promise; + parseVerifyFlaggedArgs: ( + args: string[], + ) => ParsedArgsResult; + printVerifyFlaggedUsage: () => void; + createEmptyAccountStorage: () => AccountStorageV3; + upsertRecoveredFlaggedAccount: ( + storage: AccountStorageV3, + flagged: FlaggedAccountMetadataV1, + refreshResult: Extract, + now: number, + ) => { restored: boolean; changed: boolean; message: string }; + resolveStoredAccountIdentity: ( + accountId: string | undefined, + accountIdSource: FlaggedAccountMetadataV1["accountIdSource"], + tokenAccountId: string | undefined, + ) => { + accountId?: string; + accountIdSource?: FlaggedAccountMetadataV1["accountIdSource"]; + }; + extractAccountId: (accessToken: string | undefined) => string | undefined; + extractAccountEmail: ( + accessToken: string | undefined, + idToken: string | undefined, + ) => string | undefined; + sanitizeEmail: (email: string | undefined) => string | undefined; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + withAccountAndFlaggedStorageTransaction: ( + callback: ( + loadedStorage: AccountStorageV3 | null, + persist: ( + nextStorage: AccountStorageV3, + nextFlagged: { version: 1; accounts: FlaggedAccountMetadataV1[] }, + ) => Promise, + ) => Promise, + ) => Promise; + normalizeDoctorIndexes: (storage: AccountStorageV3) => void; + saveFlaggedAccounts: (data: { + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }) => Promise; + formatAccountLabel: ( + account: Pick< + FlaggedAccountMetadataV1, + "email" | "accountLabel" | "accountId" + >, + index: number, + ) => string; + stylePromptText: ( + text: string, + tone: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + styleAccountDetailText: ( + detail: string, + fallbackTone?: "accent" | "success" | "warning" | "danger" | "muted", + ) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ + text: string; + tone: "accent" | "success" | "warning" | "danger" | "muted"; + }>, + ) => string; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +export async function runVerifyFlaggedCommand( + args: string[], + deps: VerifyFlaggedCommandDeps, +): Promise { + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + deps.printVerifyFlaggedUsage(); + return 0; + } + + const parsedArgs = deps.parseVerifyFlaggedArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + deps.printVerifyFlaggedUsage(); + return 1; + } + const options = parsedArgs.options; + + deps.setStoragePath(null); + const flaggedStorage = await deps.loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + if (options.json) { + logInfo( + JSON.stringify( + { + command: "verify-flagged", + total: 0, + restored: 0, + healthyFlagged: 0, + stillFlagged: 0, + changed: false, + dryRun: options.dryRun, + restore: options.restore, + reports: [] as VerifyFlaggedReport[], + }, + null, + 2, + ), + ); + return 0; + } + logInfo("No flagged accounts to check."); + return 0; + } + + let storageChanged = false; + let flaggedChanged = false; + const reports: VerifyFlaggedReport[] = []; + const nextFlaggedAccounts: FlaggedAccountMetadataV1[] = []; + const now = deps.getNow?.() ?? Date.now(); + const refreshChecks: Array<{ + index: number; + flagged: FlaggedAccountMetadataV1; + label: string; + result: TokenResult; + }> = []; + + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + const label = deps.formatAccountLabel(flagged, i); + refreshChecks.push({ + index: i, + flagged, + label, + result: await deps.queuedRefresh(flagged.refreshToken), + }); + } + + const applyRefreshChecks = ( + storage: AccountStorageV3, + ): { + storageChanged: boolean; + flaggedChanged: boolean; + reports: VerifyFlaggedReport[]; + nextFlaggedAccounts: FlaggedAccountMetadataV1[]; + } => { + let nextStorageChanged = false; + let nextFlaggedChanged = false; + const nextReports: VerifyFlaggedReport[] = []; + const pendingFlaggedAccounts: FlaggedAccountMetadataV1[] = []; + for (const check of refreshChecks) { + const { index: i, flagged, label, result } = check; + if (result.type === "success") { + if (!options.restore) { + const tokenAccountId = deps.extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const nextFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + deps.sanitizeEmail( + deps.extractAccountEmail(result.access, result.idToken), + ) ?? flagged.email, + lastUsed: now, + lastError: undefined, + }; + pendingFlaggedAccounts.push(nextFlagged); + if (JSON.stringify(nextFlagged) !== JSON.stringify(flagged)) + nextFlaggedChanged = true; + nextReports.push({ + index: i, + label, + outcome: "healthy-flagged", + message: + "session is healthy (left in flagged list due to --no-restore)", + }); + continue; + } + + const upsertResult = deps.upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + ); + if (upsertResult.restored) { + nextStorageChanged = nextStorageChanged || upsertResult.changed; + nextFlaggedChanged = true; + nextReports.push({ + index: i, + label, + outcome: "restored", + message: upsertResult.message, + }); + continue; + } + + const tokenAccountId = deps.extractAccountId(result.access); + const nextIdentity = deps.resolveStoredAccountIdentity( + flagged.accountId, + flagged.accountIdSource, + tokenAccountId, + ); + const updatedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + accountId: nextIdentity.accountId, + accountIdSource: nextIdentity.accountIdSource, + email: + deps.sanitizeEmail( + deps.extractAccountEmail(result.access, result.idToken), + ) ?? flagged.email, + lastUsed: now, + lastError: upsertResult.message, + }; + pendingFlaggedAccounts.push(updatedFlagged); + if (JSON.stringify(updatedFlagged) !== JSON.stringify(flagged)) + nextFlaggedChanged = true; + nextReports.push({ + index: i, + label, + outcome: "restore-skipped", + message: upsertResult.message, + }); + continue; + } + + const detail = deps.normalizeFailureDetail(result.message, result.reason); + const failedFlagged: FlaggedAccountMetadataV1 = { + ...flagged, + lastError: detail, + }; + pendingFlaggedAccounts.push(failedFlagged); + if ((flagged.lastError ?? "") !== detail) nextFlaggedChanged = true; + nextReports.push({ + index: i, + label, + outcome: "still-flagged", + message: detail, + }); + } + return { + storageChanged: nextStorageChanged, + flaggedChanged: nextFlaggedChanged, + reports: nextReports, + nextFlaggedAccounts: pendingFlaggedAccounts, + }; + }; + + const assignRefreshCheckResult = (result: { + storageChanged: boolean; + flaggedChanged: boolean; + reports: VerifyFlaggedReport[]; + nextFlaggedAccounts: FlaggedAccountMetadataV1[]; + }): void => { + storageChanged = result.storageChanged; + flaggedChanged = result.flaggedChanged; + reports.length = 0; + reports.push(...result.reports); + nextFlaggedAccounts.length = 0; + nextFlaggedAccounts.push(...result.nextFlaggedAccounts); + }; + + if (options.restore) { + if (options.dryRun) { + assignRefreshCheckResult( + applyRefreshChecks( + (await deps.loadAccounts()) ?? deps.createEmptyAccountStorage(), + ), + ); + } else { + let transactionResult: + | { + storageChanged: boolean; + flaggedChanged: boolean; + reports: VerifyFlaggedReport[]; + nextFlaggedAccounts: FlaggedAccountMetadataV1[]; + } + | undefined; + await deps.withAccountAndFlaggedStorageTransaction( + async (loadedStorage, persist) => { + const nextStorage = loadedStorage + ? structuredClone(loadedStorage) + : deps.createEmptyAccountStorage(); + const attemptResult = applyRefreshChecks(nextStorage); + if (!attemptResult.storageChanged) { + transactionResult = attemptResult; + return; + } + deps.normalizeDoctorIndexes(nextStorage); + await persist(nextStorage, { + version: 1, + accounts: attemptResult.nextFlaggedAccounts, + }); + transactionResult = attemptResult; + }, + ); + if (!transactionResult) { + logError( + "verify-flagged: transaction completed without a result; storage may be unchanged", + ); + return 1; + } + assignRefreshCheckResult(transactionResult); + } + } else { + assignRefreshCheckResult( + applyRefreshChecks(deps.createEmptyAccountStorage()), + ); + } + + const remainingFlagged = nextFlaggedAccounts.length; + const restored = reports.filter( + (report) => report.outcome === "restored", + ).length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; + const changed = storageChanged || flaggedChanged; + + if ( + !options.dryRun && + flaggedChanged && + (!options.restore || !storageChanged) + ) { + await deps.saveFlaggedAccounts({ + version: 1, + accounts: nextFlaggedAccounts, + }); + } + + if (options.json) { + logInfo( + JSON.stringify( + { + command: "verify-flagged", + total: flaggedStorage.accounts.length, + restored, + healthyFlagged, + stillFlagged, + remainingFlagged, + changed, + dryRun: options.dryRun, + restore: options.restore, + reports, + }, + null, + 2, + ), + ); + return 0; + } + + logInfo( + deps.stylePromptText( + `Checking ${flaggedStorage.accounts.length} flagged account(s)...`, + "accent", + ), + ); + for (const report of reports) { + const tone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" || + report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" || + report.outcome === "restore-skipped" + ? "!" + : "✗"; + logInfo( + `${deps.stylePromptText(marker, tone)} ${deps.stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${deps.stylePromptText("|", "muted")} ${deps.styleAccountDetailText(report.message, tone)}`, + ); + } + logInfo(""); + logInfo( + deps.formatResultSummary([ + { + text: `${restored} restored`, + tone: restored > 0 ? "success" : "muted", + }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); + if (options.dryRun) { + logInfo( + deps.stylePromptText("Preview only: no changes were saved.", "warning"), + ); + } else if (!changed) { + logInfo(deps.stylePromptText("No storage changes were needed.", "muted")); + } + + return 0; +} diff --git a/test/codex-manager-verify-flagged-command.test.ts b/test/codex-manager-verify-flagged-command.test.ts new file mode 100644 index 00000000..3af1f9ee --- /dev/null +++ b/test/codex-manager-verify-flagged-command.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runVerifyFlaggedCommand, + type VerifyFlaggedCliOptions, + type VerifyFlaggedCommandDeps, +} from "../lib/codex-manager/commands/verify-flagged.js"; +import type { + AccountStorageV3, + FlaggedAccountMetadataV1, +} from "../lib/storage.js"; + +function createFlaggedAccount( + overrides: Partial = {}, +): FlaggedAccountMetadataV1 { + return { + email: "flagged@example.com", + refreshToken: "refresh-flagged", + addedAt: 1, + ...overrides, + }; +} + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }; +} + +function createDeps( + overrides: Partial = {}, +): VerifyFlaggedCommandDeps { + const parse = vi.fn((args: string[]) => { + if (args.includes("--bad")) + return { ok: false as const, message: "Unknown option: --bad" }; + return { + ok: true as const, + options: { + dryRun: false, + json: true, + restore: true, + } satisfies VerifyFlaggedCliOptions, + }; + }); + return { + setStoragePath: vi.fn(), + loadFlaggedAccounts: vi.fn(async () => ({ + version: 1 as const, + accounts: [createFlaggedAccount()], + })), + loadAccounts: vi.fn(async () => createStorage()), + queuedRefresh: vi.fn(async () => ({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + })), + parseVerifyFlaggedArgs: parse, + printVerifyFlaggedUsage: vi.fn(), + createEmptyAccountStorage: vi.fn(() => createStorage()), + upsertRecoveredFlaggedAccount: vi.fn(() => ({ + restored: false, + changed: false, + message: "restore skipped", + })), + resolveStoredAccountIdentity: vi.fn(() => ({})), + extractAccountId: vi.fn(() => undefined), + extractAccountEmail: vi.fn(() => undefined), + sanitizeEmail: vi.fn((email) => email), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + withAccountAndFlaggedStorageTransaction: vi.fn(async (callback) => { + await callback(createStorage(), async () => undefined); + }), + normalizeDoctorIndexes: vi.fn(), + saveFlaggedAccounts: vi.fn(async () => undefined), + formatAccountLabel: vi.fn(() => "1. flagged@example.com"), + stylePromptText: vi.fn((text) => text), + styleAccountDetailText: vi.fn((text) => text), + formatResultSummary: vi.fn((segments) => + segments.map((segment) => segment.text).join(" | "), + ), + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + }; +} + +describe("runVerifyFlaggedCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runVerifyFlaggedCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.printVerifyFlaggedUsage).toHaveBeenCalled(); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runVerifyFlaggedCommand(["--bad"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bad"); + }); + + it("emits json output for failed flagged refreshes", async () => { + const deps = createDeps(); + const result = await runVerifyFlaggedCommand([], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"command": "verify-flagged"'), + ); + }); + + it("keeps retry-local flagged state isolated across transaction retries", async () => { + const persistCalls: Array<{ version: 1; accounts: FlaggedAccountMetadataV1[] }> = + []; + const deps = createDeps({ + loadFlaggedAccounts: vi.fn(async () => ({ + version: 1 as const, + accounts: [ + createFlaggedAccount({ + email: "restored@example.com", + refreshToken: "refresh-restored", + }), + createFlaggedAccount({ + email: "still@example.com", + refreshToken: "refresh-still", + }), + ], + })), + queuedRefresh: vi + .fn() + .mockResolvedValueOnce({ + type: "success", + access: "restored-access", + refresh: "restored-refresh", + expires: 5_000, + }) + .mockResolvedValueOnce({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + }), + upsertRecoveredFlaggedAccount: vi.fn(() => ({ + restored: true, + changed: true, + message: "restored", + })), + withAccountAndFlaggedStorageTransaction: vi.fn(async (callback) => { + let attempt = 0; + const persist = async ( + _nextStorage: AccountStorageV3, + nextFlagged: { version: 1; accounts: FlaggedAccountMetadataV1[] }, + ): Promise => { + persistCalls.push({ + version: nextFlagged.version, + accounts: nextFlagged.accounts.map((account) => ({ ...account })), + }); + attempt += 1; + if (attempt === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + }; + + try { + await callback(createStorage(), persist); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBUSY") throw error; + await callback(createStorage(), persist); + } + }), + }); + + const result = await runVerifyFlaggedCommand([], deps); + + expect(result).toBe(0); + expect(persistCalls).toHaveLength(2); + expect(persistCalls[0]!.accounts).toHaveLength(1); + expect(persistCalls[1]!.accounts).toHaveLength(1); + expect(persistCalls[1]!.accounts[0]).toEqual( + expect.objectContaining({ email: "still@example.com" }), + ); + + const payload = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)![0], + ); + expect(payload.remainingFlagged).toBe(1); + expect(payload.reports).toHaveLength(2); + expect(deps.saveFlaggedAccounts).not.toHaveBeenCalled(); + }); + + it("keeps healthy accounts flagged when --no-restore is selected", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: true, + restore: false, + } satisfies VerifyFlaggedCliOptions, + })), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "healthy-access", + refresh: "healthy-refresh", + expires: 5_000, + })), + resolveStoredAccountIdentity: vi.fn(() => ({ + accountId: "acct_healthy", + accountIdSource: "jwt", + })), + extractAccountId: vi.fn(() => "acct_healthy"), + extractAccountEmail: vi.fn(() => "healthy@example.com"), + }); + + const result = await runVerifyFlaggedCommand([], deps); + const payload = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)![0], + ); + + expect(result).toBe(0); + expect(payload.healthyFlagged).toBe(1); + expect(payload.remainingFlagged).toBe(1); + expect(payload.reports[0]).toEqual( + expect.objectContaining({ + outcome: "healthy-flagged", + }), + ); + expect(deps.withAccountAndFlaggedStorageTransaction).not.toHaveBeenCalled(); + expect(deps.saveFlaggedAccounts).toHaveBeenCalledTimes(1); + }); + + it("does not persist storage changes during dry-run restore", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: true, + json: true, + restore: true, + } satisfies VerifyFlaggedCliOptions, + })), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "restored-access", + refresh: "restored-refresh", + expires: 5_000, + })), + upsertRecoveredFlaggedAccount: vi.fn(() => ({ + restored: true, + changed: true, + message: "restored", + })), + }); + + const result = await runVerifyFlaggedCommand([], deps); + const payload = JSON.parse( + (deps.logInfo as ReturnType).mock.calls.at(-1)![0], + ); + + expect(result).toBe(0); + expect(payload.dryRun).toBe(true); + expect(payload.restored).toBe(1); + expect(deps.withAccountAndFlaggedStorageTransaction).not.toHaveBeenCalled(); + expect(deps.saveFlaggedAccounts).not.toHaveBeenCalled(); + }); + + it("prints a human summary for non-json verification output", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: false, + restore: false, + } satisfies VerifyFlaggedCliOptions, + })), + queuedRefresh: vi.fn(async () => ({ + type: "failed", + reason: "invalid_grant", + message: "token expired", + })), + stylePromptText: vi.fn((text) => `styled:${text}`), + styleAccountDetailText: vi.fn((text) => `detail:${text}`), + formatResultSummary: vi.fn(() => "summary:0 restored"), + }); + + const result = await runVerifyFlaggedCommand([], deps); + + expect(result).toBe(0); + expect(deps.stylePromptText).toHaveBeenCalledWith( + "Checking 1 flagged account(s)...", + "accent", + ); + expect(deps.formatResultSummary).toHaveBeenCalledWith([ + { text: "0 restored", tone: "muted" }, + { text: "0 healthy (kept flagged)", tone: "muted" }, + { text: "1 still flagged", tone: "danger" }, + ]); + expect(deps.logInfo).toHaveBeenCalledWith("summary:0 restored"); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining("detail:token expired"), + ); + }); + + it("returns early when no flagged accounts are stored", async () => { + const deps = createDeps({ + parseVerifyFlaggedArgs: vi.fn(() => ({ + ok: true as const, + options: { + dryRun: false, + json: false, + restore: true, + } satisfies VerifyFlaggedCliOptions, + })), + loadFlaggedAccounts: vi.fn(async () => ({ + version: 1 as const, + accounts: [], + })), + }); + + const result = await runVerifyFlaggedCommand([], deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith("No flagged accounts to check."); + expect(deps.queuedRefresh).not.toHaveBeenCalled(); + }); +});