From 7c9e1bada0cc94c67cab5492d59b36b6cef43133 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 21:44:18 +0000 Subject: [PATCH] refactor(cli): dedupe channel auth resolution flow --- src/cli/channel-auth.test.ts | 129 +++++++++++++++++++++++++++++++++++ src/cli/channel-auth.ts | 49 +++++++------ 2 files changed, 158 insertions(+), 20 deletions(-) create mode 100644 src/cli/channel-auth.test.ts diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts new file mode 100644 index 00000000000..2510e058869 --- /dev/null +++ b/src/cli/channel-auth.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js"; +import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; + +const mocks = vi.hoisted(() => ({ + resolveChannelDefaultAccountId: vi.fn(), + getChannelPlugin: vi.fn(), + normalizeChannelId: vi.fn(), + loadConfig: vi.fn(), + setVerbose: vi.fn(), + login: vi.fn(), + logoutAccount: vi.fn(), + resolveAccount: vi.fn(), +})); + +vi.mock("../channels/plugins/helpers.js", () => ({ + resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + getChannelPlugin: mocks.getChannelPlugin, + normalizeChannelId: mocks.normalizeChannelId, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../globals.js", () => ({ + setVerbose: mocks.setVerbose, +})); + +describe("channel-auth", () => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const plugin = { + auth: { login: mocks.login }, + gateway: { logoutAccount: mocks.logoutAccount }, + config: { resolveAccount: mocks.resolveAccount }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.normalizeChannelId.mockReturnValue("whatsapp"); + mocks.getChannelPlugin.mockReturnValue(plugin); + mocks.loadConfig.mockReturnValue({ channels: {} }); + mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account"); + mocks.resolveAccount.mockReturnValue({ id: "resolved-account" }); + mocks.login.mockResolvedValue(undefined); + mocks.logoutAccount.mockResolvedValue(undefined); + }); + + it("runs login with explicit trimmed account and verbose flag", async () => { + await runChannelLogin({ channel: "wa", account: " acct-1 ", verbose: true }, runtime); + + expect(mocks.setVerbose).toHaveBeenCalledWith(true); + expect(mocks.resolveChannelDefaultAccountId).not.toHaveBeenCalled(); + expect(mocks.login).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: { channels: {} }, + accountId: "acct-1", + runtime, + verbose: true, + channelInput: "wa", + }), + ); + }); + + it("runs login with default channel/account when opts are empty", async () => { + await runChannelLogin({}, runtime); + + expect(mocks.normalizeChannelId).toHaveBeenCalledWith(DEFAULT_CHAT_CHANNEL); + expect(mocks.resolveChannelDefaultAccountId).toHaveBeenCalledWith({ + plugin, + cfg: { channels: {} }, + }); + expect(mocks.login).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "default-account", + channelInput: DEFAULT_CHAT_CHANNEL, + }), + ); + }); + + it("throws for unsupported channel aliases", async () => { + mocks.normalizeChannelId.mockReturnValueOnce(undefined); + + await expect(runChannelLogin({ channel: "bad-channel" }, runtime)).rejects.toThrow( + "Unsupported channel: bad-channel", + ); + expect(mocks.login).not.toHaveBeenCalled(); + }); + + it("throws when channel does not support login", async () => { + mocks.getChannelPlugin.mockReturnValueOnce({ + auth: {}, + gateway: { logoutAccount: mocks.logoutAccount }, + config: { resolveAccount: mocks.resolveAccount }, + }); + + await expect(runChannelLogin({ channel: "whatsapp" }, runtime)).rejects.toThrow( + "Channel whatsapp does not support login", + ); + }); + + it("runs logout with resolved account and explicit account id", async () => { + await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime); + + expect(mocks.resolveAccount).toHaveBeenCalledWith({ channels: {} }, "acct-2"); + expect(mocks.logoutAccount).toHaveBeenCalledWith({ + cfg: { channels: {} }, + accountId: "acct-2", + account: { id: "resolved-account" }, + runtime, + }); + expect(mocks.setVerbose).not.toHaveBeenCalled(); + }); + + it("throws when channel does not support logout", async () => { + mocks.getChannelPlugin.mockReturnValueOnce({ + auth: { login: mocks.login }, + gateway: {}, + config: { resolveAccount: mocks.resolveAccount }, + }); + + await expect(runChannelLogout({ channel: "whatsapp" }, runtime)).rejects.toThrow( + "Channel whatsapp does not support logout", + ); + }); +}); diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index f7c9d85eab1..7c4d68d5c6b 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -11,24 +11,42 @@ type ChannelAuthOptions = { verbose?: boolean; }; -export async function runChannelLogin( +type ChannelPlugin = NonNullable>; +type ChannelAuthMode = "login" | "logout"; + +function resolveChannelPluginForMode( opts: ChannelAuthOptions, - runtime: RuntimeEnv = defaultRuntime, -) { + mode: ChannelAuthMode, +): { channelInput: string; channelId: string; plugin: ChannelPlugin } { const channelInput = opts.channel ?? DEFAULT_CHAT_CHANNEL; const channelId = normalizeChannelId(channelInput); if (!channelId) { throw new Error(`Unsupported channel: ${channelInput}`); } const plugin = getChannelPlugin(channelId); - if (!plugin?.auth?.login) { - throw new Error(`Channel ${channelId} does not support login`); + const supportsMode = + mode === "login" ? Boolean(plugin?.auth?.login) : Boolean(plugin?.gateway?.logoutAccount); + if (!supportsMode) { + throw new Error(`Channel ${channelId} does not support ${mode}`); } - // Auth-only flow: do not mutate channel config here. - setVerbose(Boolean(opts.verbose)); + return { channelInput, channelId, plugin: plugin as ChannelPlugin }; +} + +function resolveAccountContext(plugin: ChannelPlugin, opts: ChannelAuthOptions) { const cfg = loadConfig(); const accountId = opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg }); - await plugin.auth.login({ + return { cfg, accountId }; +} + +export async function runChannelLogin( + opts: ChannelAuthOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const { channelInput, plugin } = resolveChannelPluginForMode(opts, "login"); + // Auth-only flow: do not mutate channel config here. + setVerbose(Boolean(opts.verbose)); + const { cfg, accountId } = resolveAccountContext(plugin, opts); + await plugin.auth!.login({ cfg, accountId, runtime, @@ -41,20 +59,11 @@ export async function runChannelLogout( opts: ChannelAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const channelInput = opts.channel ?? DEFAULT_CHAT_CHANNEL; - const channelId = normalizeChannelId(channelInput); - if (!channelId) { - throw new Error(`Unsupported channel: ${channelInput}`); - } - const plugin = getChannelPlugin(channelId); - if (!plugin?.gateway?.logoutAccount) { - throw new Error(`Channel ${channelId} does not support logout`); - } + const { plugin } = resolveChannelPluginForMode(opts, "logout"); // Auth-only flow: resolve account + clear session state only. - const cfg = loadConfig(); - const accountId = opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg }); + const { cfg, accountId } = resolveAccountContext(plugin, opts); const account = plugin.config.resolveAccount(cfg, accountId); - await plugin.gateway.logoutAccount({ + await plugin.gateway!.logoutAccount({ cfg, accountId, account,