import { beforeEach, describe, expect, it, vi } from "vitest"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; const mocks = vi.hoisted(() => ({ resolveAgentWorkspaceDir: vi.fn(), resolveDefaultAgentId: vi.fn(), getChannelPluginCatalogEntry: vi.fn(), resolveChannelDefaultAccountId: vi.fn(), getChannelPlugin: vi.fn(), normalizeChannelId: vi.fn(), loadConfig: vi.fn(), writeConfigFile: vi.fn(), resolveMessageChannelSelection: vi.fn(), setVerbose: vi.fn(), createClackPrompter: vi.fn(), ensureChannelSetupPluginInstalled: vi.fn(), loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(), login: vi.fn(), logoutAccount: vi.fn(), resolveAccount: vi.fn(), })); vi.mock("../agents/agent-scope.js", () => ({ resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, resolveDefaultAgentId: mocks.resolveDefaultAgentId, })); vi.mock("../channels/plugins/catalog.js", () => ({ getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry, })); 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, writeConfigFile: mocks.writeConfigFile, })); vi.mock("../infra/outbound/channel-selection.js", () => ({ resolveMessageChannelSelection: mocks.resolveMessageChannelSelection, })); vi.mock("../globals.js", () => ({ setVerbose: mocks.setVerbose, })); vi.mock("../wizard/clack-prompter.js", () => ({ createClackPrompter: mocks.createClackPrompter, })); vi.mock("../commands/channel-setup/plugin-install.js", () => ({ ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled, loadChannelSetupPluginRegistrySnapshotForChannel: mocks.loadChannelSetupPluginRegistrySnapshotForChannel, })); describe("channel-auth", () => { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; const plugin = { id: "whatsapp", auth: { login: mocks.login }, gateway: { logoutAccount: mocks.logoutAccount }, config: { resolveAccount: mocks.resolveAccount }, }; beforeEach(() => { vi.clearAllMocks(); mocks.normalizeChannelId.mockReturnValue("whatsapp"); mocks.getChannelPlugin.mockReturnValue(plugin); mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); mocks.loadConfig.mockReturnValue({ channels: {} }); mocks.writeConfigFile.mockResolvedValue(undefined); mocks.resolveMessageChannelSelection.mockResolvedValue({ channel: "whatsapp", configured: ["whatsapp"], }); mocks.resolveDefaultAgentId.mockReturnValue("main"); mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace"); mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account"); mocks.createClackPrompter.mockReturnValue({} as object); mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({ cfg: { channels: {} }, installed: true, pluginId: "whatsapp", }); mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ channels: [{ plugin }], channelSetups: [], }); 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("auto-picks the single configured channel when opts are empty", async () => { await runChannelLogin({}, runtime); expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({ cfg: { channels: {} } }); expect(mocks.normalizeChannelId).toHaveBeenCalledWith("whatsapp"); expect(mocks.login).toHaveBeenCalledWith( expect.objectContaining({ channelInput: "whatsapp", }), ); }); it("propagates channel ambiguity when channel is omitted", async () => { mocks.resolveMessageChannelSelection.mockRejectedValueOnce( new Error("Channel is required when multiple channels are configured: telegram, slack"), ); await expect(runChannelLogin({}, runtime)).rejects.toThrow("Channel is required"); expect(mocks.login).not.toHaveBeenCalled(); }); 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("installs a catalog-backed channel plugin on demand for login", async () => { mocks.getChannelPlugin.mockReturnValueOnce(undefined); mocks.getChannelPluginCatalogEntry.mockReturnValueOnce({ id: "whatsapp", pluginId: "@openclaw/whatsapp", meta: { id: "whatsapp", label: "WhatsApp", selectionLabel: "WhatsApp", docsPath: "/channels/whatsapp", blurb: "wa", }, install: { npmSpec: "@openclaw/whatsapp", }, }); mocks.loadChannelSetupPluginRegistrySnapshotForChannel .mockReturnValueOnce({ channels: [], channelSetups: [], }) .mockReturnValueOnce({ channels: [{ plugin }], channelSetups: [], }); await runChannelLogin({ channel: "whatsapp" }, runtime); expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( expect.objectContaining({ entry: expect.objectContaining({ id: "whatsapp" }), runtime, workspaceDir: "/tmp/workspace", }), ); expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( expect.objectContaining({ channel: "whatsapp", pluginId: "whatsapp", workspaceDir: "/tmp/workspace", }), ); expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: {} }); expect(mocks.login).toHaveBeenCalled(); }); 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", ); }); });