From 093e1806439bdee6545312c97e4ae76146d067b0 Mon Sep 17 00:00:00 2001 From: Chang Work Date: Tue, 4 Aug 2026 14:05:54 -0300 Subject: [PATCH 1/2] Let reconnect change a connection's access level A read-only connection could not be upgraded to trading from the CLI: `reconnect` took no connection type and `handleConnect` explicitly sent `connectionType: undefined` for every reconnect, so the API always kept the existing level. Users with only read-only connections hit "No valid accounts available" on `trade` and were then told to run `reconnect`, which reported nothing to do because it only listed disabled connections. - `reconnect --connection-type `, forwarded to the login call. Omitting it still preserves the current level. - Without an id, `--connection-type trade` also lists active read-only connections, skipping brokerages that cannot trade. - With an id, validate the brokerage supports trading first; the API error for this has no handler and would print a raw stack. - `selectAccount` now reports the per-account rejection reasons it already computes, and names the matching remedy. --- README.md | 40 ++--- src/commands/reconnect.ts | 71 +++++++-- src/utils/connect.ts | 10 +- src/utils/selectAccount.ts | 27 +++- test/accounts-select.test.ts | 77 +++++++++- test/reconnect-command.test.ts | 269 +++++++++++++++++++++++++++++++++ 6 files changed, 457 insertions(+), 37 deletions(-) create mode 100644 test/reconnect-command.test.ts diff --git a/README.md b/README.md index 291c9a0..ec509d3 100644 --- a/README.md +++ b/README.md @@ -122,28 +122,28 @@ Usage: snaptrade [options] [command] CLI tool to interact with SnapTrade API Options: - -V, --version output the version number - --useLastAccount Use the last selected account for account specific commands (default: false) - --verbose Enable verbose output (default: false) - -h, --help display help for command + -V, --version output the version number + --useLastAccount Use the last selected account for account specific commands (default: false) + --verbose Enable verbose output (default: false) + -h, --help display help for command Commands: - status Get current status of your SnapTrade authentication - brokers List all brokers available to connect - connect [options] Establish a new broker connection - reconnect [connectionId] Re-establish an existing disabled connection - disconnect [connectionId] Remove an existing broker connection - connections List all broker connections - accounts List all connected accounts - positions [options] List all positions for a given account - recent-orders List the most recent orders (within last 24 hours) for a given account - orders List all orders for a given account - instruments Get a list of available instruments from a broker - quote [symbols] Get the latest market quote - trade [options] Execute different types of trades (equity, options, crypto) - cancel-order [options] Cancel an existing order - profiles Manage SnapTrade CLI profiles - help [command] display help for command + status Get current status of your SnapTrade authentication + brokers List all brokers available to connect + connect [options] Establish a new broker connection + reconnect [options] [connectionId] Re-establish a disabled connection, or change an existing connection's access level + disconnect [connectionId] Remove an existing broker connection + connections List all broker connections + accounts List all connected accounts + positions [options] List all positions for a given account + recent-orders List the most recent orders (within last 24 hours) for a given account + orders List all orders for a given account + instruments Get a list of available instruments from a broker + quote [symbols] Get the latest market quote + trade [options] Execute different types of trades (equity, options, crypto) + cancel-order [options] Cancel an existing order + profiles Manage SnapTrade CLI profiles + help [command] display help for command ``` ## ☕️ Development diff --git a/src/commands/reconnect.ts b/src/commands/reconnect.ts index 8383376..81b246f 100644 --- a/src/commands/reconnect.ts +++ b/src/commands/reconnect.ts @@ -1,39 +1,87 @@ import { select } from "@inquirer/prompts"; +import chalk from "chalk"; import { Command } from "commander"; import type { SnaptradeClient } from "../utils/snaptradeClient.ts"; import { handleConnect } from "../utils/connect.ts"; import { loadOrRegisterUser } from "../utils/user.ts"; +const CONNECTION_TYPES = ["read", "trade"] as const; +type ConnectionType = (typeof CONNECTION_TYPES)[number]; + export function reconnectCommand(snaptrade: SnaptradeClient): Command { return new Command("reconnect") - .description("Re-establish an existing disabled connection") + .description( + "Re-establish a disabled connection, or change an existing connection's access level", + ) .argument("[connectionId]", "Connection ID to reconnect") - .action(async (connectionId: string | undefined) => { + .option( + "--connection-type ", + `Access level to reconnect with (${CONNECTION_TYPES.join(", ")}). Defaults to keeping the current level.`, + (value: string) => { + if (!CONNECTION_TYPES.includes(value as ConnectionType)) { + console.error( + `Invalid connection type. Allowed values are: ${CONNECTION_TYPES.join(", ")}`, + ); + process.exit(1); + } + return value as ConnectionType; + }, + ) + .action(async (connectionId: string | undefined, opts) => { const user = await loadOrRegisterUser(snaptrade); + const connectionType = opts.connectionType as ConnectionType | undefined; // Prompt for connection ID if not provided const existingConnectionId = await (async () => { if (connectionId) { + // The API rejects connectionType=trade for brokerages with no trade + // auth type, and there's no global handler to turn that into + // something readable, so check before launching the portal. + if (connectionType === "trade") { + const connection = ( + await snaptrade.connections.detailBrokerageAuthorization({ + ...user, + authorizationId: connectionId, + }) + ).data; + if (connection?.brokerage?.allows_trading === false) { + console.error( + `${connection.brokerage.display_name} does not support trading through SnapTrade.`, + ); + process.exit(1); + } + } return connectionId; } const connections = ( await snaptrade.connections.listBrokerageAuthorizations(user) ).data; - const disabled = connections.filter((conn) => conn.disabled); + // Disabled connections need repair. Read-only ones are healthy but can't + // trade, so only offer them when the user asked to upgrade — and only + // where the brokerage actually supports trading. + const candidates = connections.filter((conn) => + connectionType === "trade" + ? conn.brokerage?.allows_trading !== false && + (conn.disabled || conn.type === "read") + : conn.disabled, + ); - if (disabled.length === 0) { + if (candidates.length === 0) { return null; } - if (disabled.length === 1) { - return disabled[0].id; + if (candidates.length === 1) { + return candidates[0].id; } return select({ - message: "Select a connection to reconnect", - choices: disabled.map((conn) => ({ - name: `${conn.brokerage?.display_name}`, + message: + connectionType === "trade" + ? "Select a connection to upgrade to trading" + : "Select a connection to reconnect", + choices: candidates.map((conn) => ({ + name: `${conn.brokerage?.display_name}${conn.disabled ? " (disabled)" : " (read-only)"}`, value: conn.id, })), }); @@ -41,7 +89,9 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command { if (!existingConnectionId) { console.log( - "No disabled connections found, therefore there's no need to reconnect.", + connectionType === "trade" + ? "No connections found that need upgrading to trading." + : `No disabled connections found, therefore there's no need to reconnect. To give an existing connection trading access, run ${chalk.green("snaptrade reconnect --connection-type trade")}.`, ); return; } @@ -50,6 +100,7 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command { snaptrade, user, existingConnectionId, + connectionType, }); }); } diff --git a/src/utils/connect.ts b/src/utils/connect.ts index 400e3ba..6b86ebe 100644 --- a/src/utils/connect.ts +++ b/src/utils/connect.ts @@ -8,7 +8,7 @@ export async function handleConnect({ user, existingConnectionId, brokerSlug, - connectionType = "trade-if-available", + connectionType, }: { snaptrade: SnaptradeClient; user: User; @@ -16,12 +16,16 @@ export async function handleConnect({ brokerSlug?: string; connectionType?: "read" | "trade-if-available" | "trade"; }) { + // On a reconnect an unspecified type means "keep what the connection already + // has" — the API preserves it — so only default for brand new connections. + const requestedConnectionType = + connectionType ?? (existingConnectionId ? undefined : "trade-if-available"); + const loginResponse = await snaptrade.authentication.loginSnapTradeUser({ ...user, reconnect: existingConnectionId, broker: brokerSlug, - // Don't modify connection type if reconnecting - connectionType: existingConnectionId ? undefined : connectionType, + connectionType: requestedConnectionType, }); if ( !("redirectURI" in loginResponse.data) || diff --git a/src/utils/selectAccount.ts b/src/utils/selectAccount.ts index b1fc55d..ef0e0df 100644 --- a/src/utils/selectAccount.ts +++ b/src/utils/selectAccount.ts @@ -104,8 +104,33 @@ export async function selectAccount({ ("disabled" in choice && choice.disabled), ) ) { + // Every account was rejected. The per-account reasons are already computed + // above, so report them instead of guessing at a remedy — the most common + // cause is a read-only connection, which `reconnect` alone will not fix. + const reasons = new Set( + choices.flatMap((choice) => + choice instanceof Separator || typeof choice.disabled !== "string" + ? [] + : [choice.disabled], + ), + ); + + console.error("No valid accounts available."); + for (const reason of reasons) { + console.error(` • ${reason}`); + } + if (reasons.has("Read-only connection")) { + console.error( + `\nGive an existing connection trading access with ${chalk.green(`snaptrade reconnect --connection-type trade`)}.`, + ); + } + if (reasons.has("Connection disabled")) { + console.error( + `\nRepair your disabled connections with ${chalk.green(`snaptrade reconnect`)}.`, + ); + } console.error( - `No valid accounts available. Connect an account with ${chalk.green(`snaptrade connect`)} or fix your disabled connections with ${chalk.green(`snaptrade reconnect`)}.`, + `\nRun ${chalk.green(`snaptrade connections`)} to review them, or ${chalk.green(`snaptrade connect`)} to add another.`, ); process.exit(1); } diff --git a/test/accounts-select.test.ts b/test/accounts-select.test.ts index edf8014..9cc8086 100644 --- a/test/accounts-select.test.ts +++ b/test/accounts-select.test.ts @@ -201,8 +201,79 @@ describe("account list and selection", () => { }), ).rejects.toThrow("process.exit"); expect(exitSpy).toHaveBeenCalledWith(1); - expect(stripAnsi(consoleOutput.error.join("\n"))).toContain( - "No valid accounts available. Connect an account with snaptrade connect", - ); + const output = stripAnsi(consoleOutput.error.join("\n")); + expect(output).toContain("No valid accounts available."); + expect(output).toContain("• Crypto trading not supported"); + expect(output).toContain("snaptrade connections"); + // Nothing here is disabled or read-only, so neither remedy should be offered. + expect(output).not.toContain("--connection-type trade"); + expect(output).not.toContain("Repair your disabled connections"); + }); + + it("points read-only rejections at the connection-type upgrade, not at reconnect-repair", async () => { + useIsolatedConfigHome(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + vi.doMock("../src/utils/user.ts", () => ({ + loadOrRegisterUser: vi.fn().mockResolvedValue({}), + })); + vi.doMock("../src/utils/accounts.ts", () => ({ + listAccountsByConnection: vi.fn().mockResolvedValue([ + { + connection: { + id: "read-only", + disabled: false, + type: "read", + brokerage: { name: "Alpaca", slug: "ALPACA" }, + }, + accounts: [ + { + id: "acct-read", + name: "Alpaca Margin", + institution_name: "Alpaca", + balance: { total: { amount: 1, currency: "USD" } }, + }, + ], + }, + { + connection: { + id: "no-mleg", + disabled: false, + type: "trade", + brokerage: { name: "Questrade", slug: "QUESTRADE" }, + }, + accounts: [ + { + id: "acct-qt", + name: "Individual Margin", + institution_name: "Questrade", + balance: { total: { amount: 2, currency: "CAD" } }, + }, + ], + }, + ]), + })); + vi.doMock("@inquirer/prompts", () => ({ + select: vi.fn(), + })); + const consoleOutput = captureConsole(); + + const { selectAccount } = await import("../src/utils/selectAccount.ts"); + + await expect( + selectAccount({ + snaptrade: createMockSnaptrade(), + useLastAccount: false, + context: "option_trade", + }), + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + const output = stripAnsi(consoleOutput.error.join("\n")); + expect(output).toContain("• Read-only connection"); + expect(output).toContain("• Option trading not supported"); + expect(output).toContain("snaptrade reconnect --connection-type trade"); + // The old message sent read-only users to disabled-connection repair. + expect(output).not.toContain("Repair your disabled connections"); }); }); diff --git a/test/reconnect-command.test.ts b/test/reconnect-command.test.ts new file mode 100644 index 0000000..eb2c811 --- /dev/null +++ b/test/reconnect-command.test.ts @@ -0,0 +1,269 @@ +import { mkdirSync } from "fs"; +import { join } from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + captureConsole, + createMockSnaptrade, + parseCommand, + stripAnsi, + useIsolatedConfigHome, +} from "./helpers/cli.ts"; + +// A personal api-key profile short-circuits loadOrRegisterUser, so the command +// under test never reaches the registration prompt or the network. +async function usePersonalProfile() { + const configHome = useIsolatedConfigHome(); + mkdirSync(join(configHome, "snaptrade"), { recursive: true }); + const settings = await import("../src/utils/settings.ts"); + settings.saveProfile({ authMode: "apiKey", accountType: "personal" }); +} + +describe("reconnect command", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("upgrades an active read-only connection when --connection-type trade is passed", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-read", + type: "read", + disabled: false, + brokerage: { display_name: "Alpaca" }, + }, + ], + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "trade", + ]); + + expect(handleConnect).toHaveBeenCalledTimes(1); + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "conn-read", + connectionType: "trade", + }); + }); + + it("forwards an explicit connection id and type without listing connections", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.detailBrokerageAuthorization, + ).mockResolvedValue({ + data: { brokerage: { display_name: "Alpaca", allows_trading: true } }, + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "2e582a27-82cc-4453-8b75-6a93a37b2bc8", + "--connection-type", + "trade", + ]); + + expect( + snaptrade.connections.listBrokerageAuthorizations, + ).not.toHaveBeenCalled(); + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "2e582a27-82cc-4453-8b75-6a93a37b2bc8", + connectionType: "trade", + }); + }); + + it("ignores healthy read-only connections when no upgrade was requested", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-read", + type: "read", + disabled: false, + brokerage: { display_name: "Alpaca" }, + }, + ], + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), ["reconnect"]); + + expect(handleConnect).not.toHaveBeenCalled(); + const output = stripAnsi(consoleOutput.log.join("\n")); + expect(output).toContain("No disabled connections found"); + // The dead end that sent SNAP-9604's reporter in circles: say what to do next. + expect(output).toContain("snaptrade reconnect --connection-type trade"); + }); + + it("refuses to upgrade a brokerage that cannot trade, instead of a raw API error", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.detailBrokerageAuthorization, + ).mockResolvedValue({ + data: { + brokerage: { + display_name: "Interactive Brokers Flex", + allows_trading: false, + }, + }, + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await expect( + parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "b922c247-fe01-4a6e-b08b-48e76f96a8e8", + "--connection-type", + "trade", + ]), + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(handleConnect).not.toHaveBeenCalled(); + expect(stripAnsi(consoleOutput.error.join("\n"))).toContain( + "Interactive Brokers Flex does not support trading", + ); + }); + + it("skips trade-incapable brokerages when listing upgrade candidates", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-flex", + type: "read", + disabled: false, + brokerage: { + display_name: "Interactive Brokers Flex", + allows_trading: false, + }, + }, + { + id: "conn-alpaca", + type: "read", + disabled: false, + brokerage: { display_name: "Alpaca", allows_trading: true }, + }, + ], + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "trade", + ]); + + // Only one viable candidate, so it is selected without prompting. + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "conn-alpaca", + }); + }); + + it("rejects an unknown connection type", async () => { + await usePersonalProfile(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect: vi.fn() })); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await expect( + parseCommand(reconnectCommand(createMockSnaptrade()), [ + "reconnect", + "--connection-type", + "trade-if-available", + ]), + ).rejects.toThrow("process.exit"); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(stripAnsi(consoleOutput.error.join("\n"))).toContain( + "Allowed values are: read, trade", + ); + }); +}); + +describe("handleConnect connection type", () => { + beforeEach(() => { + // The reconnect suite above doMocks this module and the registration + // outlives resetModules, so drop it before exercising the real thing. + vi.doUnmock("../src/utils/connect.ts"); + vi.resetModules(); + }); + + async function callHandleConnect(args: Record) { + vi.doMock("open", () => ({ default: vi.fn() })); + vi.useFakeTimers(); + const loginSnapTradeUser = vi.fn().mockResolvedValue({ + data: { redirectURI: "https://app.snaptrade.com/portal?token=t" }, + }); + const snaptrade = createMockSnaptrade({ + authentication: { loginSnapTradeUser }, + } as never); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ data: [] }); + captureConsole(); + + const { handleConnect } = await import("../src/utils/connect.ts"); + await handleConnect({ snaptrade, user: {}, ...args }); + vi.clearAllTimers(); + vi.useRealTimers(); + + return loginSnapTradeUser.mock.calls[0][0] as Record; + } + + it("keeps the existing type when reconnecting without an explicit type", async () => { + const payload = await callHandleConnect({ + existingConnectionId: "conn-1", + }); + expect(payload.reconnect).toBe("conn-1"); + expect(payload.connectionType).toBeUndefined(); + }); + + it("forwards an explicit type on reconnect so read-only can be upgraded", async () => { + const payload = await callHandleConnect({ + existingConnectionId: "conn-1", + connectionType: "trade", + }); + expect(payload.connectionType).toBe("trade"); + }); + + it("still defaults new connections to trade-if-available", async () => { + const payload = await callHandleConnect({}); + expect(payload.connectionType).toBe("trade-if-available"); + }); +}); From ffe72ceebb999bb26bc45b2ec07a6060b1aa2e34 Mon Sep 17 00:00:00 2001 From: Chang Work Date: Tue, 4 Aug 2026 14:28:48 -0300 Subject: [PATCH 2/2] Offer active trade connections when downgrading to read The candidate filter only added healthy connections for a trade upgrade, so `reconnect --connection-type read` without an id considered disabled connections only. An active trade connection was never offered and the command reported nothing to do, even though the requested downgrade was available. Select on whether the requested level differs from the current one, in either direction, and keep the allows_trading check on upgrades only. --- src/commands/reconnect.ts | 38 ++++++++++++------ test/reconnect-command.test.ts | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/commands/reconnect.ts b/src/commands/reconnect.ts index 81b246f..24b8dd5 100644 --- a/src/commands/reconnect.ts +++ b/src/commands/reconnect.ts @@ -57,15 +57,25 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command { await snaptrade.connections.listBrokerageAuthorizations(user) ).data; - // Disabled connections need repair. Read-only ones are healthy but can't - // trade, so only offer them when the user asked to upgrade — and only - // where the brokerage actually supports trading. - const candidates = connections.filter((conn) => - connectionType === "trade" - ? conn.brokerage?.allows_trading !== false && - (conn.disabled || conn.type === "read") - : conn.disabled, - ); + // Disabled connections always need repair. Healthy ones are only worth + // offering when the requested access level differs from what they + // already have — and an upgrade additionally needs a brokerage that can + // actually trade. + const candidates = connections.filter((conn) => { + if (conn.disabled) { + return ( + connectionType !== "trade" || + conn.brokerage?.allows_trading !== false + ); + } + if (!connectionType || conn.type === connectionType) { + return false; + } + return ( + connectionType !== "trade" || + conn.brokerage?.allows_trading !== false + ); + }); if (candidates.length === 0) { return null; @@ -79,9 +89,11 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command { message: connectionType === "trade" ? "Select a connection to upgrade to trading" - : "Select a connection to reconnect", + : connectionType === "read" + ? "Select a connection to make read-only" + : "Select a connection to reconnect", choices: candidates.map((conn) => ({ - name: `${conn.brokerage?.display_name}${conn.disabled ? " (disabled)" : " (read-only)"}`, + name: `${conn.brokerage?.display_name}${conn.disabled ? " (disabled)" : conn.type === "read" ? " (read-only)" : " (trade)"}`, value: conn.id, })), }); @@ -91,7 +103,9 @@ export function reconnectCommand(snaptrade: SnaptradeClient): Command { console.log( connectionType === "trade" ? "No connections found that need upgrading to trading." - : `No disabled connections found, therefore there's no need to reconnect. To give an existing connection trading access, run ${chalk.green("snaptrade reconnect --connection-type trade")}.`, + : connectionType === "read" + ? "No connections found that need changing to read-only." + : `No disabled connections found, therefore there's no need to reconnect. To give an existing connection trading access, run ${chalk.green("snaptrade reconnect --connection-type trade")}.`, ); return; } diff --git a/test/reconnect-command.test.ts b/test/reconnect-command.test.ts index eb2c811..2d9439b 100644 --- a/test/reconnect-command.test.ts +++ b/test/reconnect-command.test.ts @@ -193,6 +193,78 @@ describe("reconnect command", () => { }); }); + it("offers active trade connections when downgrading to read", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-trade", + type: "trade", + disabled: false, + brokerage: { display_name: "Alpaca", allows_trading: true }, + }, + // Already read-only: nothing to change, so it must not be a candidate. + { + id: "conn-read", + type: "read", + disabled: false, + brokerage: { display_name: "Moomoo", allows_trading: true }, + }, + ], + }); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "read", + ]); + + expect(handleConnect.mock.calls[0][0]).toMatchObject({ + existingConnectionId: "conn-trade", + connectionType: "read", + }); + }); + + it("does not offer connections that already have the requested level", async () => { + await usePersonalProfile(); + const handleConnect = vi.fn(); + vi.doMock("../src/utils/connect.ts", () => ({ handleConnect })); + + const snaptrade = createMockSnaptrade(); + vi.mocked( + snaptrade.connections.listBrokerageAuthorizations, + ).mockResolvedValue({ + data: [ + { + id: "conn-trade", + type: "trade", + disabled: false, + brokerage: { display_name: "Alpaca", allows_trading: true }, + }, + ], + }); + const consoleOutput = captureConsole(); + + const { reconnectCommand } = await import("../src/commands/reconnect.ts"); + await parseCommand(reconnectCommand(snaptrade), [ + "reconnect", + "--connection-type", + "trade", + ]); + + expect(handleConnect).not.toHaveBeenCalled(); + expect(stripAnsi(consoleOutput.log.join("\n"))).toContain( + "No connections found that need upgrading to trading.", + ); + }); + it("rejects an unknown connection type", async () => { await usePersonalProfile(); vi.doMock("../src/utils/connect.ts", () => ({ handleConnect: vi.fn() }));