fix(feishu): feishu connector name validate (#797)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -105,6 +105,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
|||||||
### 📡 マルチチャネル管理
|
### 📡 マルチチャネル管理
|
||||||
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
||||||
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
|
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
|
||||||
|
カスタムのチャンネルアカウント ID には、ルーティング不一致を防ぐため OpenClaw 互換の正規形式(`[a-z0-9_-]`、英小文字、最大 64 文字、先頭は英小文字または数字)を必須にしています。
|
||||||
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
|
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
|
||||||
|
|
||||||
### ⏰ Cronベースの自動化
|
### ⏰ Cronベースの自動化
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ Each agent can also override its own `provider/model` runtime setting; agents wi
|
|||||||
### 📡 Multi-Channel Management
|
### 📡 Multi-Channel Management
|
||||||
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
||||||
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
|
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
|
||||||
|
For custom channel account IDs, ClawX enforces OpenClaw-compatible canonical IDs (`[a-z0-9_-]`, lowercase, max 64 chars, must start with a letter/number) to prevent routing mismatches.
|
||||||
ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow.
|
ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow.
|
||||||
|
|
||||||
### ⏰ Cron-Based Automation
|
### ⏰ Cron-Based Automation
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
|||||||
### 📡 多频道管理
|
### 📡 多频道管理
|
||||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||||
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
|
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
|
||||||
|
对于自定义频道账号 ID,ClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。
|
||||||
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
|
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
|
||||||
|
|
||||||
### ⏰ 定时任务自动化
|
### ⏰ 定时任务自动化
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
OPENCLAW_WECHAT_CHANNEL_TYPE,
|
OPENCLAW_WECHAT_CHANNEL_TYPE,
|
||||||
UI_WECHAT_CHANNEL_TYPE,
|
UI_WECHAT_CHANNEL_TYPE,
|
||||||
buildQrChannelEventName,
|
buildQrChannelEventName,
|
||||||
|
isCanonicalOpenClawAccountId,
|
||||||
toOpenClawChannelType,
|
toOpenClawChannelType,
|
||||||
toUiChannelType,
|
toUiChannelType,
|
||||||
} from '../../utils/channel-alias';
|
} from '../../utils/channel-alias';
|
||||||
@@ -89,6 +90,47 @@ function buildQrLoginKey(channelType: string, accountId?: string): string {
|
|||||||
return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`;
|
return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isLegacyConfiguredAccountId(channelType: string, accountId: string): Promise<boolean> {
|
||||||
|
const config = await readOpenClawConfig();
|
||||||
|
const configuredAccounts = listConfiguredChannelAccountsFromConfig(config) ?? {};
|
||||||
|
const storedChannelType = resolveStoredChannelType(channelType);
|
||||||
|
const knownAccountIds = configuredAccounts[storedChannelType]?.accountIds ?? [];
|
||||||
|
return knownAccountIds.includes(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateCanonicalAccountId(
|
||||||
|
channelType: string,
|
||||||
|
accountId: string | undefined,
|
||||||
|
options?: { allowLegacyConfiguredId?: boolean },
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!accountId) return null;
|
||||||
|
const trimmed = accountId.trim();
|
||||||
|
if (!trimmed) return 'accountId cannot be empty';
|
||||||
|
if (isCanonicalOpenClawAccountId(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options?.allowLegacyConfiguredId && await isLegacyConfiguredAccountId(channelType, trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Backward compatibility note:
|
||||||
|
// existing legacy IDs can still be edited/bound if they already exist in config.
|
||||||
|
// new account IDs must be canonical to match OpenClaw runtime routing behavior.
|
||||||
|
return 'Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAccountIdOrReply(
|
||||||
|
res: ServerResponse,
|
||||||
|
channelType: string,
|
||||||
|
accountId: string | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const error = await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||||
|
if (!error) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendJson(res, 400, { success: false, error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string {
|
function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string {
|
||||||
const loginKey = buildQrLoginKey(channelType, accountId);
|
const loginKey = buildQrLoginKey(channelType, accountId);
|
||||||
activeQrLogins.set(loginKey, sessionKey);
|
activeQrLogins.set(loginKey, sessionKey);
|
||||||
@@ -1087,6 +1129,10 @@ export async function handleChannelRoutes(
|
|||||||
if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') {
|
if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
|
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
|
||||||
|
const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId);
|
||||||
|
if (!validAccountId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
await setChannelDefaultAccount(body.channelType, body.accountId);
|
await setChannelDefaultAccount(body.channelType, body.accountId);
|
||||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`);
|
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
@@ -1099,6 +1145,10 @@ export async function handleChannelRoutes(
|
|||||||
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
|
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req);
|
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req);
|
||||||
|
const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId);
|
||||||
|
if (!validAccountId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
await assignChannelAccountToAgent(body.agentId, resolveStoredChannelType(body.channelType), body.accountId);
|
await assignChannelAccountToAgent(body.agentId, resolveStoredChannelType(body.channelType), body.accountId);
|
||||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
|
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
@@ -1111,6 +1161,10 @@ export async function handleChannelRoutes(
|
|||||||
if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') {
|
if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
|
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
|
||||||
|
const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId);
|
||||||
|
if (!validAccountId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId);
|
await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId);
|
||||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`);
|
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
@@ -1212,6 +1266,10 @@ export async function handleChannelRoutes(
|
|||||||
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
|
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
|
||||||
|
const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId);
|
||||||
|
if (!validAccountId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const storedChannelType = resolveStoredChannelType(body.channelType);
|
const storedChannelType = resolveStoredChannelType(body.channelType);
|
||||||
if (storedChannelType === 'dingtalk') {
|
if (storedChannelType === 'dingtalk') {
|
||||||
const installResult = await ensureDingTalkPluginInstalled();
|
const installResult = await ensureDingTalkPluginInstalled();
|
||||||
|
|||||||
@@ -44,3 +44,9 @@ export function normalizeOpenClawAccountId(value: string | null | undefined, fal
|
|||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCanonicalOpenClawAccountId(value: string | null | undefined): boolean {
|
||||||
|
const trimmed = (value ?? '').trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
return normalizeOpenClawAccountId(trimmed, '') === trimmed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import {
|
|||||||
type ChannelMeta,
|
type ChannelMeta,
|
||||||
type ChannelConfigField,
|
type ChannelConfigField,
|
||||||
} from '@/types/channel';
|
} from '@/types/channel';
|
||||||
import { buildQrChannelEventName, usesPluginManagedQrAccounts } from '@/lib/channel-alias';
|
import {
|
||||||
|
buildQrChannelEventName,
|
||||||
|
isCanonicalOpenClawAccountId,
|
||||||
|
usesPluginManagedQrAccounts,
|
||||||
|
} from '@/lib/channel-alias';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import telegramIcon from '@/assets/channels/telegram.svg';
|
import telegramIcon from '@/assets/channels/telegram.svg';
|
||||||
@@ -82,6 +86,7 @@ export function ChannelConfigModal({
|
|||||||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||||
const [channelName, setChannelName] = useState('');
|
const [channelName, setChannelName] = useState('');
|
||||||
const [accountIdInput, setAccountIdInput] = useState(accountId || '');
|
const [accountIdInput, setAccountIdInput] = useState(accountId || '');
|
||||||
|
const [accountIdError, setAccountIdError] = useState<string | null>(null);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||||
@@ -115,6 +120,7 @@ export function ChannelConfigModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAccountIdInput(accountId || '');
|
setAccountIdInput(accountId || '');
|
||||||
|
setAccountIdError(null);
|
||||||
}, [accountId]);
|
}, [accountId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,6 +131,7 @@ export function ChannelConfigModal({
|
|||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
setQrCode(null);
|
setQrCode(null);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
|
setAccountIdError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,16 +359,28 @@ export function ChannelConfigModal({
|
|||||||
if (showAccountIdEditor) {
|
if (showAccountIdEditor) {
|
||||||
const nextAccountId = accountIdInput.trim();
|
const nextAccountId = accountIdInput.trim();
|
||||||
if (!nextAccountId) {
|
if (!nextAccountId) {
|
||||||
toast.error(t('account.invalidId'));
|
const message = t('account.invalidId');
|
||||||
|
setAccountIdError(message);
|
||||||
|
toast.error(message);
|
||||||
|
setConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isCanonicalOpenClawAccountId(nextAccountId)) {
|
||||||
|
const message = t('account.invalidCanonicalId');
|
||||||
|
setAccountIdError(message);
|
||||||
|
toast.error(message);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const duplicateExists = existingAccountIds.some((id) => id === nextAccountId && id !== (accountId || '').trim());
|
const duplicateExists = existingAccountIds.some((id) => id === nextAccountId && id !== (accountId || '').trim());
|
||||||
if (duplicateExists) {
|
if (duplicateExists) {
|
||||||
toast.error(t('account.accountIdExists', { accountId: nextAccountId }));
|
const message = t('account.accountIdExists', { accountId: nextAccountId });
|
||||||
|
setAccountIdError(message);
|
||||||
|
toast.error(message);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setAccountIdError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.connectionType === 'qr') {
|
if (meta.connectionType === 'qr') {
|
||||||
@@ -643,11 +662,20 @@ export function ChannelConfigModal({
|
|||||||
<Input
|
<Input
|
||||||
id="account-id"
|
id="account-id"
|
||||||
value={accountIdInput}
|
value={accountIdInput}
|
||||||
onChange={(event) => setAccountIdInput(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setAccountIdInput(event.target.value);
|
||||||
|
if (accountIdError) {
|
||||||
|
setAccountIdError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={t('account.customIdPlaceholder')}
|
placeholder={t('account.customIdPlaceholder')}
|
||||||
className={inputClasses}
|
className={cn(inputClasses, accountIdError && 'border-destructive/50 focus-visible:ring-destructive/30')}
|
||||||
/>
|
/>
|
||||||
<p className="text-[12px] text-muted-foreground">{t('account.customIdHint')}</p>
|
{accountIdError ? (
|
||||||
|
<p className="text-[12px] text-destructive">{accountIdError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t('account.customIdHint')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,9 @@
|
|||||||
"mainAccount": "Primary Account",
|
"mainAccount": "Primary Account",
|
||||||
"customIdLabel": "Account ID",
|
"customIdLabel": "Account ID",
|
||||||
"customIdPlaceholder": "e.g. feishu-sales-bot",
|
"customIdPlaceholder": "e.g. feishu-sales-bot",
|
||||||
"customIdHint": "Use a custom account ID to distinguish multiple accounts under one channel.",
|
"customIdHint": "Use a lowercase account ID (letters, numbers, hyphen, underscore) to distinguish multiple accounts under one channel.",
|
||||||
"invalidId": "Account ID cannot be empty",
|
"invalidId": "Account ID cannot be empty",
|
||||||
|
"invalidCanonicalId": "Account ID must use lowercase letters, numbers, hyphens, or underscores, start with a letter/number, and be at most 64 characters.",
|
||||||
"idLabel": "ID: {{id}}",
|
"idLabel": "ID: {{id}}",
|
||||||
"boundTo": "Bound to: {{agent}}",
|
"boundTo": "Bound to: {{agent}}",
|
||||||
"handledBy": "Handled by {{agent}}",
|
"handledBy": "Handled by {{agent}}",
|
||||||
|
|||||||
@@ -47,8 +47,9 @@
|
|||||||
"mainAccount": "メインアカウント",
|
"mainAccount": "メインアカウント",
|
||||||
"customIdLabel": "アカウント ID",
|
"customIdLabel": "アカウント ID",
|
||||||
"customIdPlaceholder": "例: feishu-sales-bot",
|
"customIdPlaceholder": "例: feishu-sales-bot",
|
||||||
"customIdHint": "同じチャンネル内の複数アカウントを区別するため、任意の ID を設定できます。",
|
"customIdHint": "同じチャンネル内の複数アカウントを区別するため、英小文字・数字・ハイフン・アンダースコアのみの ID を設定してください。",
|
||||||
"invalidId": "アカウント ID は空にできません",
|
"invalidId": "アカウント ID は空にできません",
|
||||||
|
"invalidCanonicalId": "アカウント ID は英小文字・数字・ハイフン・アンダースコアのみ使用でき、先頭は英小文字または数字、最大 64 文字です。",
|
||||||
"idLabel": "ID: {{id}}",
|
"idLabel": "ID: {{id}}",
|
||||||
"boundTo": "割り当て先: {{agent}}",
|
"boundTo": "割り当て先: {{agent}}",
|
||||||
"handledBy": "{{agent}} が処理",
|
"handledBy": "{{agent}} が処理",
|
||||||
|
|||||||
@@ -47,8 +47,9 @@
|
|||||||
"mainAccount": "主账号",
|
"mainAccount": "主账号",
|
||||||
"customIdLabel": "账号 ID",
|
"customIdLabel": "账号 ID",
|
||||||
"customIdPlaceholder": "例如:feishu-sales-bot",
|
"customIdPlaceholder": "例如:feishu-sales-bot",
|
||||||
"customIdHint": "可自定义账号 ID,用于区分同一频道下的多个账号。",
|
"customIdHint": "使用小写账号 ID(字母、数字、连字符、下划线)来区分同一频道下的多个账号。",
|
||||||
"invalidId": "账号 ID 不能为空",
|
"invalidId": "账号 ID 不能为空",
|
||||||
|
"invalidCanonicalId": "账号 ID 仅支持小写字母、数字、连字符和下划线;必须以字母或数字开头,且最长 64 个字符。",
|
||||||
"idLabel": "ID: {{id}}",
|
"idLabel": "ID: {{id}}",
|
||||||
"boundTo": "绑定对象:{{agent}}",
|
"boundTo": "绑定对象:{{agent}}",
|
||||||
"handledBy": "由 {{agent}} 处理",
|
"handledBy": "由 {{agent}} 处理",
|
||||||
|
|||||||
@@ -48,3 +48,9 @@ export function normalizeOpenClawAccountId(value: string | null | undefined, fal
|
|||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCanonicalOpenClawAccountId(value: string | null | undefined): boolean {
|
||||||
|
const trimmed = (value ?? '').trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
return normalizeOpenClawAccountId(trimmed, '') === trimmed;
|
||||||
|
}
|
||||||
|
|||||||
94
tests/e2e/channels-account-id-validation.spec.ts
Normal file
94
tests/e2e/channels-account-id-validation.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { completeSetup, expect, test } from './fixtures/electron';
|
||||||
|
|
||||||
|
const testConfigResponses = {
|
||||||
|
channelsAccounts: {
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: 'feishu',
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
name: 'Primary Account',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
success: true,
|
||||||
|
agents: [],
|
||||||
|
},
|
||||||
|
credentialsValidate: {
|
||||||
|
success: true,
|
||||||
|
valid: true,
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
channelConfig: {
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Channels account ID validation', () => {
|
||||||
|
test('rejects non-canonical custom account ID before save', async ({ electronApp, page }) => {
|
||||||
|
await electronApp.evaluate(({ ipcMain }, responses) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).__clawxE2eChannelConfigSaveCount = 0;
|
||||||
|
ipcMain.removeHandler('hostapi:fetch');
|
||||||
|
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
|
||||||
|
const method = request?.method ?? 'GET';
|
||||||
|
const path = request?.path ?? '';
|
||||||
|
|
||||||
|
if (path === '/api/channels/accounts' && method === 'GET') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: responses.channelsAccounts } };
|
||||||
|
}
|
||||||
|
if (path === '/api/agents' && method === 'GET') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: responses.agents } };
|
||||||
|
}
|
||||||
|
if (path === '/api/channels/credentials/validate' && method === 'POST') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: responses.credentialsValidate } };
|
||||||
|
}
|
||||||
|
if (path === '/api/channels/config' && method === 'POST') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).__clawxE2eChannelConfigSaveCount += 1;
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: responses.channelConfig } };
|
||||||
|
}
|
||||||
|
if (path.startsWith('/api/channels/config/') && method === 'GET') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, testConfigResponses);
|
||||||
|
|
||||||
|
await completeSetup(page);
|
||||||
|
|
||||||
|
await page.getByTestId('sidebar-nav-channels').click();
|
||||||
|
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||||
|
await expect(page.getByText('Feishu / Lark')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Add Account|account\.add/i }).click();
|
||||||
|
await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('#account-id').fill('测试账号');
|
||||||
|
await page.locator('#appId').fill('cli_test');
|
||||||
|
await page.locator('#appSecret').fill('secret_test');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/ }).click();
|
||||||
|
await expect(page.getByText(/account\.invalidCanonicalId|must use lowercase letters/i).first()).toBeVisible();
|
||||||
|
|
||||||
|
const saveCalls = await electronApp.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const count = Number((globalThis as any).__clawxE2eChannelConfigSaveCount || 0);
|
||||||
|
return { count };
|
||||||
|
});
|
||||||
|
expect(saveCalls.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,11 @@ const readOpenClawConfigMock = vi.fn();
|
|||||||
const listAgentsSnapshotMock = vi.fn();
|
const listAgentsSnapshotMock = vi.fn();
|
||||||
const sendJsonMock = vi.fn();
|
const sendJsonMock = vi.fn();
|
||||||
const proxyAwareFetchMock = vi.fn();
|
const proxyAwareFetchMock = vi.fn();
|
||||||
|
const saveChannelConfigMock = vi.fn();
|
||||||
|
const setChannelDefaultAccountMock = vi.fn();
|
||||||
|
const assignChannelAccountToAgentMock = vi.fn();
|
||||||
|
const clearChannelBindingMock = vi.fn();
|
||||||
|
const parseJsonBodyMock = vi.fn();
|
||||||
const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw');
|
const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw');
|
||||||
|
|
||||||
vi.mock('@electron/utils/channel-config', () => ({
|
vi.mock('@electron/utils/channel-config', () => ({
|
||||||
@@ -22,17 +27,17 @@ vi.mock('@electron/utils/channel-config', () => ({
|
|||||||
listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args),
|
listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args),
|
||||||
listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args),
|
listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args),
|
||||||
readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
|
readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
|
||||||
saveChannelConfig: vi.fn(),
|
saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args),
|
||||||
setChannelDefaultAccount: vi.fn(),
|
setChannelDefaultAccount: (...args: unknown[]) => setChannelDefaultAccountMock(...args),
|
||||||
setChannelEnabled: vi.fn(),
|
setChannelEnabled: vi.fn(),
|
||||||
validateChannelConfig: vi.fn(),
|
validateChannelConfig: vi.fn(),
|
||||||
validateChannelCredentials: vi.fn(),
|
validateChannelCredentials: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@electron/utils/agent-config', () => ({
|
vi.mock('@electron/utils/agent-config', () => ({
|
||||||
assignChannelAccountToAgent: vi.fn(),
|
assignChannelAccountToAgent: (...args: unknown[]) => assignChannelAccountToAgentMock(...args),
|
||||||
clearAllBindingsForChannel: vi.fn(),
|
clearAllBindingsForChannel: vi.fn(),
|
||||||
clearChannelBinding: vi.fn(),
|
clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args),
|
||||||
listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args),
|
listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args),
|
||||||
listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotMock(...args),
|
listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotMock(...args),
|
||||||
}));
|
}));
|
||||||
@@ -59,7 +64,7 @@ vi.mock('@electron/utils/whatsapp-login', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@electron/api/route-utils', () => ({
|
vi.mock('@electron/api/route-utils', () => ({
|
||||||
parseJsonBody: vi.fn().mockResolvedValue({}),
|
parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
|
||||||
sendJson: (...args: unknown[]) => sendJsonMock(...args),
|
sendJson: (...args: unknown[]) => sendJsonMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -93,6 +98,8 @@ describe('handleChannelRoutes', () => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
|
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
|
||||||
proxyAwareFetchMock.mockReset();
|
proxyAwareFetchMock.mockReset();
|
||||||
|
parseJsonBodyMock.mockResolvedValue({});
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({});
|
||||||
listAgentsSnapshotMock.mockResolvedValue({
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
agents: [],
|
agents: [],
|
||||||
channelAccountOwners: {},
|
channelAccountOwners: {},
|
||||||
@@ -194,6 +201,214 @@ describe('handleChannelRoutes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects non-canonical account ID on channel config save', async () => {
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'feishu',
|
||||||
|
accountId: '测试账号',
|
||||||
|
config: { appId: 'cli_xxx', appSecret: 'secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
const handled = await handleChannelRoutes(
|
||||||
|
{ method: 'POST' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/config'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
400,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.stringContaining('Invalid accountId format'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(saveChannelConfigMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows legacy non-canonical account ID on channel config save when account already exists', async () => {
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'Legacy_Account',
|
||||||
|
config: { botToken: 'token', allowedUsers: '123456' },
|
||||||
|
});
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'Legacy_Account'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
const handled = await handleChannelRoutes(
|
||||||
|
{ method: 'POST' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/config'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||||
|
'telegram',
|
||||||
|
{ botToken: 'token', allowedUsers: '123456' },
|
||||||
|
'Legacy_Account',
|
||||||
|
);
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
200,
|
||||||
|
expect.objectContaining({ success: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-canonical account ID on default-account route', async () => {
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'feishu',
|
||||||
|
accountId: 'ABC',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/default-account'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
400,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.stringContaining('Invalid accountId format'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(setChannelDefaultAccountMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-canonical account ID on binding routes', async () => {
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'feishu',
|
||||||
|
accountId: 'Account-Upper',
|
||||||
|
agentId: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
400,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.stringContaining('Invalid accountId format'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'feishu',
|
||||||
|
accountId: 'INVALID VALUE',
|
||||||
|
});
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'DELETE' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
expect(clearChannelBindingMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows legacy non-canonical account ID on default-account and binding routes', async () => {
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
feishu: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'Legacy_Account'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'feishu',
|
||||||
|
accountId: 'Legacy_Account',
|
||||||
|
});
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/default-account'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'Legacy_Account');
|
||||||
|
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'feishu',
|
||||||
|
accountId: 'Legacy_Account',
|
||||||
|
agentId: 'main',
|
||||||
|
});
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'Legacy_Account');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps channel connected when one account is healthy and another errors', async () => {
|
it('keeps channel connected when one account is healthy and another errors', async () => {
|
||||||
listConfiguredChannelsMock.mockResolvedValue(['telegram']);
|
listConfiguredChannelsMock.mockResolvedValue(['telegram']);
|
||||||
listConfiguredChannelAccountsMock.mockResolvedValue({
|
listConfiguredChannelAccountsMock.mockResolvedValue({
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Channels } from '@/pages/Channels/index';
|
|||||||
|
|
||||||
const hostApiFetchMock = vi.fn();
|
const hostApiFetchMock = vi.fn();
|
||||||
const subscribeHostEventMock = vi.fn();
|
const subscribeHostEventMock = vi.fn();
|
||||||
|
const toastSuccessMock = vi.fn();
|
||||||
|
const toastErrorMock = vi.fn();
|
||||||
|
const toastWarningMock = vi.fn();
|
||||||
|
|
||||||
const { gatewayState } = vi.hoisted(() => ({
|
const { gatewayState } = vi.hoisted(() => ({
|
||||||
gatewayState: {
|
gatewayState: {
|
||||||
@@ -31,9 +34,9 @@ vi.mock('react-i18next', () => ({
|
|||||||
|
|
||||||
vi.mock('sonner', () => ({
|
vi.mock('sonner', () => ({
|
||||||
toast: {
|
toast: {
|
||||||
success: vi.fn(),
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||||
error: vi.fn(),
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||||
warning: vi.fn(),
|
warning: (...args: unknown[]) => toastWarningMock(...args),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -83,6 +86,94 @@ describe('Channels page status refresh', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks saving when custom account ID is non-canonical', async () => {
|
||||||
|
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||||
|
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||||
|
if (path === '/api/channels/accounts') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: 'feishu',
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
name: 'Primary Account',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/agents') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
agents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/channels/credentials/validate') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
valid: true,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/channels/config') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected host API path: ${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Channels />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Feishu / Lark')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'account.add' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('dialog.configureTitle')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('account.customIdLabel'), {
|
||||||
|
target: { value: '测试账号' },
|
||||||
|
});
|
||||||
|
const appIdInput = document.getElementById('appId') as HTMLInputElement | null;
|
||||||
|
const appSecretInput = document.getElementById('appSecret') as HTMLInputElement | null;
|
||||||
|
expect(appIdInput).not.toBeNull();
|
||||||
|
expect(appSecretInput).not.toBeNull();
|
||||||
|
fireEvent.change(appIdInput!, { target: { value: 'cli_test' } });
|
||||||
|
fireEvent.change(appSecretInput!, { target: { value: 'secret_test' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'dialog.saveAndConnect' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('account.invalidCanonicalId')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(toastErrorMock).toHaveBeenCalledWith('account.invalidCanonicalId');
|
||||||
|
|
||||||
|
const saveCalls = hostApiFetchMock.mock.calls.filter(([path, init]) => (
|
||||||
|
path === '/api/channels/config'
|
||||||
|
&& typeof init === 'object'
|
||||||
|
&& init != null
|
||||||
|
&& 'method' in init
|
||||||
|
&& (init as { method?: string }).method === 'POST'
|
||||||
|
));
|
||||||
|
expect(saveCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('refetches channel accounts when gateway channel-status events arrive', async () => {
|
it('refetches channel accounts when gateway channel-status events arrive', async () => {
|
||||||
let channelStatusHandler: (() => void) | undefined;
|
let channelStatusHandler: (() => void) | undefined;
|
||||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {
|
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user