YxinMiracle 0b7cef2e0b
feat(channels): support WeChat QR login from the web UI (#5582)
* feat(channels): add WeChat QR login and binding recovery

* fix(channels): enforce single-worker WeChat QR login and preserve bot ID

Reject QR login endpoints when multiple Gateway workers are configured, while keeping manual token setup available.

Preserve the configured bot ID when the provider omits it or returns an empty value. Add regression coverage for worker guards and credential persistence.

* fix(channels): sync WeChat completion state on provider updates

Show the connected step when refreshed provider data confirms the binding, so the dialog no longer waits indefinitely after polling is cancelled.

Add regression tests for provider updates during pending poll and binding requests, including late responses and expiry.

* fix(channels): preserve WeChat pairing codes across waits and redirects

---------

Co-authored-by: YxinMiracle <“939157765@qq.com”>
2026-09-20 17:21:49 +08:00

267 lines
7.5 KiB
TypeScript

import { beforeEach, describe, expect, test, rs } from "@rstest/core";
rs.mock("@/core/api/fetcher", () => ({
fetch: rs.fn(),
}));
rs.mock("@/core/config", () => ({
getBackendBaseURL: () => "/backend",
}));
import { fetch as fetcher } from "@/core/api/fetcher";
import {
cancelWechatQRLogin,
pollWechatQRLogin,
startWechatQRLogin,
configureChannelProvider,
connectChannelProvider,
disconnectChannelConnection,
disconnectChannelProvider,
listChannelConnections,
listChannelProviders,
} from "@/core/channels/api";
const mockedFetch = rs.mocked(fetcher);
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
statusText: status >= 400 ? "Bad Request" : "OK",
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
mockedFetch.mockReset();
});
describe("channels api", () => {
test("loads provider catalog", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
enabled: true,
providers: [
{
provider: "telegram",
display_name: "Telegram",
enabled: true,
configured: true,
auth_mode: "deep_link",
connection_status: "not_connected",
credential_values: {
bot_token: "********",
bot_username: "deerflow_bot",
},
},
],
}),
);
await expect(listChannelProviders()).resolves.toMatchObject({
enabled: true,
providers: [
{
provider: "telegram",
display_name: "Telegram",
credential_values: {
bot_token: "********",
bot_username: "deerflow_bot",
},
},
],
});
expect(mockedFetch).toHaveBeenCalledWith("/backend/api/channels/providers");
});
test("loads current user's connections", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
connections: [
{
id: "connection-1",
provider: "telegram",
status: "connected",
external_account_name: "Alice",
scopes: [],
metadata: {},
},
],
}),
);
await expect(listChannelConnections()).resolves.toMatchObject([
{ id: "connection-1", provider: "telegram", status: "connected" },
]);
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/connections",
);
});
test("starts a provider connection flow", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "telegram",
mode: "deep_link",
url: "https://t.me/deerflow_bot?start=state",
code: "state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
expires_in: 600,
}),
);
await expect(connectChannelProvider("telegram")).resolves.toMatchObject({
provider: "telegram",
url: "https://t.me/deerflow_bot?start=state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/telegram/connect",
{ method: "POST" },
);
});
test("starts a binding-code connection flow", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
mode: "binding_code",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
expires_in: 600,
}),
);
await expect(connectChannelProvider("slack")).resolves.toMatchObject({
provider: "slack",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
});
});
test("submits runtime provider configuration", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
display_name: "Slack",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "not_connected",
}),
);
await expect(
configureChannelProvider("slack", {
bot_token: "xoxb-ui",
app_token: "xapp-ui",
}),
).resolves.toMatchObject({
provider: "slack",
configured: true,
connectable: true,
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/slack/runtime-config",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
values: { bot_token: "xoxb-ui", app_token: "xapp-ui" },
}),
},
);
});
test("disconnects a channel connection", async () => {
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(
disconnectChannelConnection("connection-1"),
).resolves.toBeUndefined();
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/connections/connection-1",
{ method: "DELETE" },
);
});
test("disconnects provider runtime configuration", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
display_name: "Slack",
enabled: true,
configured: false,
connectable: false,
auth_mode: "binding_code",
connection_status: "not_connected",
}),
);
await expect(disconnectChannelProvider("slack")).resolves.toMatchObject({
provider: "slack",
configured: false,
connection_status: "not_connected",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/slack/runtime-config",
{ method: "DELETE" },
);
});
test("uses backend detail for failed requests", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(400, { detail: "Channel provider is not configured" }),
);
await expect(connectChannelProvider("slack")).rejects.toThrow(
"Channel provider is not configured",
);
});
});
test("WeChat QR requests use authenticated mutations and support cancellation", async () => {
const session = {
id: "qr/id",
status: "pending",
qrcode_content: "scan",
expires_in: 180,
provider: null,
};
mockedFetch.mockResolvedValueOnce(jsonResponse(200, session));
await expect(startWechatQRLogin()).resolves.toEqual(session);
expect(mockedFetch).toHaveBeenLastCalledWith(
"/backend/api/channels/wechat/qr-login",
{ method: "POST" },
);
const controller = new AbortController();
mockedFetch.mockResolvedValueOnce(jsonResponse(200, session));
await pollWechatQRLogin(session.id, controller.signal);
expect(mockedFetch).toHaveBeenLastCalledWith(
"/backend/api/channels/wechat/qr-login/qr%2Fid/poll",
{ method: "POST", signal: controller.signal },
);
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
await cancelWechatQRLogin(session.id);
expect(mockedFetch).toHaveBeenLastCalledWith(
"/backend/api/channels/wechat/qr-login/qr%2Fid",
{ method: "DELETE" },
);
});
test("submits a pairing code in the request body, never the URL", async () => {
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { status: "scanned" }));
await pollWechatQRLogin("session", undefined, "123456");
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/wechat/qr-login/session/poll",
{
method: "POST",
signal: undefined,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ verify_code: "123456" }),
},
);
});