Files
makelore/tests/unit/channel-accounts-page.test.tsx
T

1573 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts';
import { useAuthStore } from '@/stores/auth';
import {
EMPTY_CLOUD_CONFIGURATION,
type CloudAgentDraft,
type CloudPendingOperation,
type CloudUserChannelAccount,
type CloudWechatBindState,
} from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({
call: vi.fn(),
list: vi.fn(),
recovery: vi.fn(),
resolvePending: vi.fn(),
}));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
vi.mock('@/pages/CloudAgents/CloudWechatQr', () => ({
CloudWechatQr: () => <img alt="个人微信登录二维码" src="data:image/png;base64,test" />,
}));
const agentA: CloudAgentDraft = {
slug: 'ml-' + 'a'.repeat(32),
name: '写作助手',
purpose: '',
system_prompt: '',
published_version: 2,
draft_revision: 3,
updated_at: '',
configuration: EMPTY_CLOUD_CONFIGURATION,
enabled: true,
};
const agentB: CloudAgentDraft = {
...agentA,
slug: 'ml-' + 'b'.repeat(32),
name: '资料助手',
published_version: 4,
};
const legacy: CloudUserChannelAccount = {
id: 'wechat-legacy',
display_name: '原有微信',
binding_id: 'binding-legacy',
revision: 7,
enabled: true,
status: 'active',
health: 'connected',
worker_online: true,
blockers: [],
target_agent_slug: agentA.slug,
target_agent_name: agentA.name,
target_agent_published_version: 2,
sync_state: 'ready',
};
const spare: CloudUserChannelAccount = {
...legacy,
id: 'wechat-spare',
display_name: '备用微信',
revision: 2,
enabled: false,
status: 'paused',
health: 'login_required',
blockers: ['target_required'],
target_agent_slug: null,
target_agent_name: null,
target_agent_published_version: null,
};
let accounts: CloudUserChannelAccount[];
let agents: CloudAgentDraft[];
let pending: CloudPendingOperation[];
let qrStatus: CloudWechatBindState['status'];
let verifyStatus: CloudWechatBindState['status'];
const calls = (operation: string) => api.call.mock.calls.filter(([name]) => name === operation);
const writes = () =>
api.call.mock.calls.filter(
([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name)
);
function LocationProbe() {
const location = useLocation();
return <output data-testid="location-path">{location.pathname + location.search}</output>;
}
function renderPage(entry = '/cloud-agents/channels') {
return render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/cloud-agents/channels" element={<ChannelAccounts />} />
<Route path="/cloud-agents" element={<div>智能体列表</div>} />
</Routes>
<LocationProbe />
</MemoryRouter>
);
}
async function card(name = legacy.display_name) {
return screen.findByRole('article', { name: name + '微信', exact: true });
}
async function chooseExisting(name = legacy.display_name) {
const current = await card(name);
fireEvent.click(within(current).getByRole('button', { name: /更换智能体|绑定智能体/ }));
return screen.getByRole('dialog', { name: '更换智能体', exact: true });
}
async function more(name = legacy.display_name) {
const current = await card(name);
fireEvent.click(within(current).getByRole('button', { name: '更多操作', exact: true }));
return current;
}
async function adding() {
const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
await waitFor(() => expect(addButton).toBeEnabled());
fireEvent.click(addButton);
const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true });
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
await act(async () =>
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true }))
);
return dialog;
}
function installMock() {
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
if (operation === 'channelAccounts') return { items: accounts.map((item) => ({ ...item })) };
if (operation === 'createChannelAccount') {
const channel = {
...spare,
id: 'wechat-created',
display_name: String(input.display_name),
revision: 1,
};
accounts.push(channel);
return { channel: { ...channel } };
}
const account = accounts.find((item) => item.id === input.channel_account_id);
if (!account) throw new Error('Unknown fixture account');
if (operation === 'routeChannelAccount') {
const target = agents.find((item) => item.slug === input.target_agent_slug);
Object.assign(account, {
revision: account.revision + 1,
target_agent_slug: target?.slug ?? null,
target_agent_name: target?.name ?? null,
enabled: input.enabled,
desired_state: input.enabled ? 'enabled' : 'disabled',
blockers: input.enabled ? [] : ['binding_paused'],
});
return { channel: { ...account } };
}
if (operation === 'enableChannelAccount') {
const enabled = true;
Object.assign(account, {
revision: account.revision + 1,
enabled,
desired_state: 'enabled',
blockers: enabled ? [] : ['binding_paused'],
});
return { channel: { ...account } };
}
if (operation === 'unbindChannelAccount') {
Object.assign(account, {
revision: account.revision + 1,
health: 'login_required',
enabled: false,
desired_state: 'disabled',
target_agent_slug: null,
target_agent_name: null,
blockers: ['target_required'],
});
return { channel: { ...account }, unbound: true, disconnected: true };
}
if (
[
'channelAccountWechatBindStart',
'channelAccountWechatBindStatus',
'channelAccountWechatBindVerification',
].includes(operation)
) {
const status = operation === 'channelAccountWechatBindVerification' ? verifyStatus : qrStatus;
if (status === 'confirmed') account.health = 'connected';
return { session_key: 'qr-' + account.id, status, qrcode_url: 'https://login.example.test/qr' };
}
throw new Error('Unexpected fixture operation ' + operation);
});
}
beforeEach(() => {
vi.resetAllMocks();
localStorage.clear();
useAuthStore.setState({ user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] } });
accounts = [{ ...legacy }, { ...spare }];
agents = [agentA, agentB];
pending = [];
qrStatus = 'verification_required';
verifyStatus = 'pending';
api.list.mockImplementation(async () => ({ agents, next_cursor: null }));
api.recovery.mockImplementation(async () => ({ recent: null, pending }));
installMock();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});
it.each([
['none', []],
['drafts', [{ ...agentA, published_version: null }]],
['disabled', [{ ...agentA, enabled: false }]],
] satisfies [string, CloudAgentDraft[]][])(
'retains no-Agent guidance and makes no binding requests for %s Agents',
async (_name, unavailable) => {
agents = unavailable;
accounts = [];
renderPage();
expect(await screen.findByRole('heading', { name: '还没有可连接的智能体' })).toBeVisible();
expect(writes()).toEqual([]);
fireEvent.click(screen.getByRole('button', { name: '去我的智能体' }));
expect(await screen.findByText('智能体列表')).toBeVisible();
expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine');
}
);
it('keeps one main action on each card and only unbind in the healthy account menu', async () => {
renderPage();
const current = await card();
expect(within(current).getByText('使用中')).toBeVisible();
expect(within(current).getByText(agentA.name)).toBeVisible();
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeVisible();
expect(within(current).queryByRole('button', { name: '解除微信绑定' })).not.toBeInTheDocument();
const menu = await more();
expect(within(menu).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
expect(within(menu).queryByRole('button', { name: '恢复自动回复' })).not.toBeInTheDocument();
expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeVisible();
expect(screen.queryByText('断开微信连接')).not.toBeInTheDocument();
expect(screen.queryByText('活动记录')).not.toBeInTheDocument();
expect(writes()).toEqual([]);
});
it('uses one Agent-choice window and confirms a switch without a second dialog', async () => {
renderPage();
const dialog = await chooseExisting();
expect(within(dialog).getByRole('button', { name: '确认更换' })).toBeDisabled();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
expect(calls('routeChannelAccount')).toHaveLength(0);
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(calls('routeChannelAccount')).toHaveLength(1);
expect(calls('routeChannelAccount')[0][1]).toMatchObject({
channel_account_id: legacy.id,
target_agent_slug: agentB.slug,
enabled: true,
expected_revision: 7,
});
expect(await screen.findByText('已更换智能体,微信连接和历史保留。')).toBeVisible();
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
});
it('switches an old paused binding with replies enabled after choosing a healthy target', async () => {
accounts[0] = { ...legacy, enabled: false, desired_state: 'paused', blockers: ['binding_paused'] };
renderPage('/cloud-agents/channels?account=' + legacy.id + '&target=' + agentB.slug);
const dialog = await screen.findByRole('dialog', { name: '更换智能体' });
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked();
expect(calls('enableChannelAccount')).toHaveLength(0);
const confirm = await within(dialog).findByRole('button', { name: '确认更换' });
await waitFor(() => expect(confirm).toBeEnabled());
fireEvent.click(confirm);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(accounts[0].enabled).toBe(true);
expect(calls('routeChannelAccount')[0][1]).toMatchObject({ enabled: true, expected_revision: 7 });
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it('shows a rejected switch inside the still-open choice dialog and preserves the old binding', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'routeChannelAccount') throw new Error('渠道状态已变化');
return base(op, input);
});
renderPage();
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('渠道状态已变化');
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked();
expect(accounts[0].target_agent_slug).toBe(agentA.slug);
expect(screen.getAllByRole('dialog')).toHaveLength(1);
});
it('never overwrites a final read with a stale switch receipt', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'routeChannelAccount') {
await base(op, input);
return { channel: { ...legacy } };
}
return base(op, input);
});
renderPage();
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(within(await card()).getByText(agentB.name)).toBeVisible();
expect(within(await card()).getByText('使用中')).toBeVisible();
});
it('restores a healthy old paused binding once and trusts authoritative readback over a stale receipt', async () => {
accounts[0] = { ...legacy, enabled: false, desired_state: 'paused', blockers: ['binding_paused'] };
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount') {
await base(op, input);
return { channel: { ...legacy, enabled: false } };
}
return base(op, input);
});
renderPage();
await waitFor(() => expect(calls('enableChannelAccount')).toHaveLength(1));
expect(await screen.findByText('使用中')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(api.list.mock.calls.length).toBeGreaterThanOrEqual(3));
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 7 });
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it('uses unified WeChat unbind after one explicit confirmation and preserves the account card', async () => {
renderPage();
fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
expect(within(dialog).getByText(/历史仍保留/)).toBeVisible();
expect(calls('unbindChannelAccount')).toHaveLength(0);
fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(within(await card()).getByText('未绑定')).toBeVisible();
expect(within(await card()).getByRole('button', { name: '绑定智能体' })).toBeVisible();
expect(calls('unbindChannelAccount')[0][1]).toMatchObject({
channel_account_id: legacy.id,
expected_revision: 7,
});
expect(calls('disconnectChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')).toHaveLength(0);
});
it('shows unbind errors in the current dialog and never reports success from an incomplete receipt', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) =>
op === 'unbindChannelAccount'
? { channel: { ...legacy }, unbound: false, disconnected: false }
: base(op, input)
);
renderPage();
fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('解除结果还没有确认');
expect(within(dialog).getByRole('button', { name: '确认解除微信绑定' })).toBeDisabled();
expect(screen.queryByText('微信绑定已解除,历史仍保留。')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(within(await card()).getByText('状态待确认')).toBeVisible();
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
});
it('does not enable a pending or verification-required QR', async () => {
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('img', { name: '个人微信登录二维码' })).toBeVisible();
expect(calls('createChannelAccount')).toHaveLength(1);
expect(calls('routeChannelAccount')[0][1]).toMatchObject({
target_agent_slug: agentA.slug,
enabled: false,
});
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(within(dialog).queryByRole('button', { name: '完成绑定并启用' })).not.toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText('微信验证码'), { target: { value: '123456' } });
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: '提交验证码' })));
expect(await within(dialog).findByText('等待微信确认')).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('automatically enables once after genuine QR confirmation and final readback', async () => {
qrStatus = 'confirmed';
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 2 });
expect(within(dialog).queryByRole('button', { name: '完成绑定并启用' })).not.toBeInTheDocument();
expect(calls('channelAccounts').length).toBeGreaterThanOrEqual(5);
});
it('a confirmed status without authoritative login cannot enable or claim success', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) =>
op === 'channelAccountWechatBindStart' ? { session_key: 'qr', status: 'confirmed' } : base(op, input)
);
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'微信登录状态尚未确认,请先完成微信登录,再检查连接。'
);
expect(await within(dialog).findByText('扫码已确认')).toBeVisible();
expect(within(dialog).queryByText('微信登录已确认')).not.toBeInTheDocument();
expect(within(dialog).queryByText(/微信登录和智能体选择已保留/)).not.toBeInTheDocument();
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(within(dialog).getByRole('button', { name: '检查连接并完成绑定' })).toBeEnabled();
});
it('a failed automatic enable offers explicit retry without an automatic loop', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount' && ++attempts === 1) throw new Error('启用暂时失败');
return base(op, input);
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent('启用暂时失败');
expect(calls('enableChannelAccount')).toHaveLength(1);
fireEvent.click(within(dialog).getByRole('button', { name: '检查连接并完成绑定' }));
expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(2);
expect(calls('enableChannelAccount')[0][1]).toEqual(calls('enableChannelAccount')[1][1]);
});
it('stale enable receipts cannot conceal a still-disabled authoritative account', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) =>
op === 'enableChannelAccount'
? { channel: { ...accounts.find((item) => item.id === input.channel_account_id), enabled: true } }
: base(op, input)
);
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent('操作结果还没有确认');
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' }));
const current = await card('微信 3');
expect(within(current).getByText('状态待确认')).toBeVisible();
expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
});
it('does not enable a different Agent or an offline account after QR confirmation', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'channelAccountWechatBindStart')
accounts.find((item) => item.id === input.channel_account_id)!.target_agent_slug = agentB.slug;
return result;
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent('绑定的智能体已发生变化');
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('does not restore an old paused binding while its Worker is offline on entry or refresh', async () => {
accounts[0] = { ...legacy, enabled: false, worker_online: false, blockers: ['binding_paused', 'worker_offline'] };
renderPage();
await screen.findByText('微信服务离线');
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(calls('channelAccounts')).toHaveLength(2));
expect(writes()).toEqual([]);
});
it('reconnects an old paused binding and enables replies without changing its Agent', async () => {
accounts[0] = { ...legacy, health: 'login_required', enabled: false, blockers: ['binding_paused'] };
qrStatus = 'confirmed';
renderPage();
const current = await card();
fireEvent.click(within(current).getByRole('button', { name: '重新扫码' }));
const dialog = screen.getByRole('dialog', { name: '重新连接微信' });
expect(await within(dialog).findByRole('heading', { name: '微信重新连接成功' })).toBeVisible();
expect(within(dialog).getByText('现在可以回微信和写作助手聊聊。')).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, expected_revision: 7 });
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(accounts[0].target_agent_slug).toBe(agentA.slug);
});
it('stops polling after a QR confirmation and deduplicates activation', async () => {
renderPage();
await screen.findByRole('button', { name: '添加微信' });
vi.useFakeTimers({ shouldAdvanceTime: true });
const dialog = await adding();
await within(dialog).findByRole('img', { name: '个人微信登录二维码' });
qrStatus = 'confirmed';
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(within(dialog).getByRole('heading', { name: '微信绑定成功' })).toBeVisible();
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('channelAccountWechatBindStatus')).toHaveLength(1);
});
it('does not create an enable request after unmount during the authoritative QR read', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
let release: (() => void) | undefined;
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls('channelAccountWechatBindStart').length)
await new Promise<void>((resolve) => {
release = resolve;
});
return base(op, input);
});
const view = renderPage();
await adding();
await waitFor(() => expect(release).toBeTypeOf('function'));
view.unmount();
await act(async () => release!());
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('closing a pending QR does not later enable it or leave polling alive', async () => {
renderPage();
const dialog = await adding();
await within(dialog).findByRole('img', { name: '个人微信登录二维码' });
vi.useFakeTimers();
fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' }));
qrStatus = 'confirmed';
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
expect(calls('channelAccountWechatBindStatus')).toHaveLength(0);
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('retries an unsuccessful new route with the same operation, without creating another account', async () => {
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (op, input) => {
if (op === 'routeChannelAccount' && ++attempts === 1) throw new Error('绑定未完成');
return base(op, input);
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent('绑定未完成');
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
await within(dialog).findByRole('img', { name: '个人微信登录二维码' });
expect(calls('createChannelAccount')).toHaveLength(1);
expect(calls('routeChannelAccount')[0][1]).toEqual(calls('routeChannelAccount')[1][1]);
});
it('represents a pending operation honestly and prevents conflicting changes until recovery', async () => {
accounts[0].blockers = ['worker_offline'];
pending = [
{
id: 'unknown-pause',
operation: 'pauseChannelAccount',
input: { channel_account_id: legacy.id, expected_revision: 7, operation_id: 'original' },
created_at: '',
},
];
renderPage();
const current = await card();
expect(within(current).getByText('状态待确认')).toBeVisible();
expect(within(current).queryByText('微信服务离线')).not.toBeInTheDocument();
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(screen.getByRole('button', { name: '添加微信' })).toBeDisabled();
expect(writes()).toEqual([]);
pending = [];
accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
fireEvent.click(within(current).getByRole('button', { name: '刷新确认' }));
expect(await screen.findByText('使用中')).toBeVisible();
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeEnabled();
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it('does not paint local paused or degraded synchronization as usable', async () => {
accounts = [
{ ...legacy, enabled: false, worker_online: false, desired_state: 'paused', blockers: ['binding_paused'] },
{
...spare,
target_agent_slug: agentA.slug,
health: 'connected',
enabled: true,
sync_state: 'degraded',
blockers: ['channel_sync_degraded', 'worker_offline'],
},
];
renderPage();
expect(await screen.findByText('绑定待完成')).toBeVisible();
const degraded = await card(spare.display_name);
expect(within(degraded).getByText('状态待确认')).toBeVisible();
expect(within(degraded).queryByText('微信服务离线')).not.toBeInTheDocument();
expect(within(degraded).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(screen.queryByText('使用中')).not.toBeInTheDocument();
expect(writes()).toEqual([]);
});
it('deep links open the single choice dialog and refresh preserves user choice', async () => {
renderPage('/cloud-agents/channels?account=' + legacy.id + '&target=' + agentB.slug);
const dialog = await screen.findByRole('dialog', { name: '更换智能体' });
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked();
await waitFor(() => expect(screen.getByTestId('location-path')).not.toHaveTextContent('target='));
expect(writes()).toEqual([]);
});
it.each(['accounts', 'agents'])(
'keeps failed %s loading distinct from an empty list',
async (source) => {
if (source === 'accounts') api.call.mockRejectedValue(new Error('微信列表暂时不可用'));
else api.list.mockRejectedValue(new Error('智能体列表暂时不可用'));
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('暂时不可用');
expect(screen.getByRole('button', { name: '添加微信' })).toBeDisabled();
expect(screen.queryByRole('heading', { name: '把你的智能体带到微信吧' })).not.toBeInTheDocument();
}
);
it('locks changing an inconsistent local paused route without preventing safe unbind', async () => {
accounts = [{ ...legacy, desired_state: 'paused', enabled: true }];
renderPage();
const current = await card();
expect(within(current).getByText('状态待确认')).toBeVisible();
expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled();
fireEvent.click(within(current).getByRole('button', { name: '更多操作' }));
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
});
it('loads all Agent pages and offers only published enabled targets', async () => {
api.list.mockImplementation(async (cursor) =>
cursor
? {
agents: [
agentB,
{ ...agentA, slug: 'ml-' + 'c'.repeat(32), name: '未发布', published_version: null },
],
next_cursor: null,
}
: { agents: [agentA], next_cursor: 'page-2' }
);
renderPage();
const dialog = await chooseExisting();
expect(within(dialog).getByRole('radio', { name: agentA.name })).toBeVisible();
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeVisible();
expect(within(dialog).queryByRole('radio', { name: '未发布' })).not.toBeInTheDocument();
expect(api.list).toHaveBeenNthCalledWith(2, 'page-2');
});
it('keeps existing cards removable when no available Agent remains', async () => {
agents = [];
renderPage();
const current = await more();
fireEvent.click(within(current).getByRole('button', { name: '解除微信绑定' }));
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
expect(within(dialog).getByRole('button', { name: '确认解除微信绑定' })).toBeEnabled();
expect(writes()).toEqual([]);
});
it.each(['expired', 'failed'] as const)(
'offers an explicit QR refresh after %s without claiming success',
async (status) => {
qrStatus = status;
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('button', { name: '刷新二维码' })).toBeEnabled();
expect(calls('enableChannelAccount')).toHaveLength(0);
qrStatus = 'confirmed';
fireEvent.click(within(dialog).getByRole('button', { name: '刷新二维码' }));
await within(dialog).findByRole('heading', { name: '微信绑定成功' });
expect(calls('channelAccountWechatBindStart')[1][1]).toMatchObject({ force: true });
expect(calls('enableChannelAccount')).toHaveLength(1);
}
);
it('continues an existing unfinished account without creating another account slot', async () => {
accounts = [{ ...spare, health: 'connected' }];
renderPage();
const dialog = await chooseExisting(spare.display_name);
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
await within(dialog).findByRole('heading', { name: '微信绑定成功' });
expect(calls('createChannelAccount')).toHaveLength(0);
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')[0][1]).toMatchObject({ enabled: true });
});
it('explains an offline Worker after confirmed login and preserves the chosen Agent without enabling', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'channelAccountWechatBindStart')
accounts.find((item) => item.id === input.channel_account_id)!.blockers = ['worker_offline'];
return result;
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'智能体的微信服务暂时离线,暂时无法自动回复。微信登录和智能体选择已保留,服务恢复后请刷新状态。'
);
expect(await within(dialog).findByText('微信登录已确认')).toBeVisible();
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: '检查连接并完成绑定' })).toBeEnabled();
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(accounts.find((item) => item.id === 'wechat-created')).toMatchObject({
target_agent_slug: agentA.slug,
health: 'connected',
enabled: false,
});
fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' }));
const current = await card('微信 3');
expect(within(current).getByText('微信服务离线')).toBeVisible();
expect(within(current).queryByText(/智能体的微信服务暂时离线/)).not.toBeInTheDocument();
expect(within(current).getByText(agentA.name)).toBeVisible();
});
it('finishes once on explicit retry after Worker recovery without scanning again or changing the target', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'channelAccountWechatBindStart')
accounts.find((item) => item.id === input.channel_account_id)!.blockers = ['worker_offline'];
return result;
});
renderPage();
const dialog = await adding();
await within(dialog).findByRole('alert');
const created = accounts.find((item) => item.id === 'wechat-created')!;
created.blockers = ['binding_paused'];
expect(calls('enableChannelAccount')).toHaveLength(0);
fireEvent.click(within(dialog).getByRole('button', { name: '检查连接并完成绑定' }));
expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({
channel_account_id: created.id,
expected_revision: 2,
});
expect(calls('channelAccountWechatBindStart')).toHaveLength(1);
expect(calls('routeChannelAccount')).toHaveLength(1);
expect(created.target_agent_slug).toBe(agentA.slug);
});
it('reports offline readiness after the final enable read without claiming binding success', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'enableChannelAccount')
accounts.find((item) => item.id === input.channel_account_id)!.blockers = ['worker_offline'];
return result;
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'智能体的微信服务暂时离线,暂时无法自动回复。'
);
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
expect(calls('enableChannelAccount')).toHaveLength(1);
});
it.each(['login_required', 'unknown'] as const)(
'does not claim retained login for a %s account with an offline Worker',
async (health) => {
accounts[0] = { ...legacy, health, blockers: ['worker_offline'] };
renderPage();
const current = await card();
expect(within(current).queryByText('智能体的微信服务暂时离线,暂时无法自动回复。')).not.toBeInTheDocument();
expect(within(current).queryByText(/微信登录和智能体选择已保留/)).not.toBeInTheDocument();
expect(within(current).queryByText('微信服务离线')).not.toBeInTheDocument();
expect(writes()).toEqual([]);
}
);
it('recovers the original uncertain operation through the existing recovery control', async () => {
pending = [
{
id: 'original-unbind-record',
operation: 'unbindChannelAccount',
input: { channel_account_id: legacy.id, operation_id: 'original-op', expected_revision: 7 },
created_at: '2026-10-13T00:00:00Z',
},
];
api.resolvePending.mockImplementation(async (id) => {
expect(id).toBe('original-unbind-record');
Object.assign(accounts[0], { target_agent_slug: null, enabled: false, health: 'login_required' });
pending = [];
return { result: { unbound: true, disconnected: true }, operation: 'unbindChannelAccount' };
});
renderPage();
await card();
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
const recovery = screen.getByRole('dialog', { name: '待处理记录' });
fireEvent.click(within(recovery).getByRole('button', { name: '再试一次' }));
await within(recovery).findByText('解除微信绑定的结果已确认。');
fireEvent.click(within(recovery).getByRole('button', { name: '返回列表' }));
expect(within(await card()).getByText('未绑定')).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledWith('original-unbind-record', false);
expect(writes()).toEqual([]);
});
it('retains an uncertain badge when final route synchronization is degraded', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'routeChannelAccount') accounts[0].sync_state = 'degraded';
return result;
});
renderPage();
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('操作结果还没有确认');
expect(screen.queryByText('已更换智能体,微信连接和历史保留。')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '关闭' }));
expect(within(await card()).getByText('状态待确认')).toBeVisible();
});
it('ignores an older manual refresh that arrives after the final switch read', async () => {
const base = api.call.getMockImplementation()!;
const original = accounts.map((account) => ({ ...account }));
let releaseRefresh: (() => void) | undefined;
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls('channelAccounts').length === 2) {
await new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
return { items: original };
}
return base(op, input);
});
renderPage();
await card();
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(releaseRefresh).toBeTypeOf('function'));
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(within(await card()).getByText(agentB.name)).toBeVisible();
await act(async () => releaseRefresh!());
expect(within(await card()).getByText(agentB.name)).toBeVisible();
expect(within(await card()).queryByText(agentA.name)).not.toBeInTheDocument();
});
it('preserves owned cards and safe unbind when the initial Agent listing fails', async () => {
api.list.mockRejectedValue(new Error('智能体列表暂时不可用'));
renderPage();
const current = await card();
expect(within(current).getByText(agentA.name)).toBeVisible();
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled();
const menu = await more();
expect(within(menu).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
fireEvent.click(within(menu).getByRole('button', { name: '解除微信绑定' }));
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(within(await card()).getByText('未绑定')).toBeVisible();
expect(calls('unbindChannelAccount')).toHaveLength(1);
expect(calls('routeChannelAccount')).toHaveLength(0);
});
it('does not let an older refresh erase a new uncertain operation and unlock the card', async () => {
const base = api.call.getMockImplementation()!;
const original = accounts.map((account) => ({ ...account }));
let releaseRefresh: (() => void) | undefined;
api.recovery.mockImplementation(async () => ({ recent: null, pending: [...pending] }));
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls('channelAccounts').length === 2) {
await new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
return { items: original };
}
if (op === 'routeChannelAccount') {
pending = [
{
id: 'new-uncertain-route',
operation: 'routeChannelAccount',
input: { ...input },
created_at: '2026-10-13T00:00:00Z',
},
];
throw new Error('还没有确认切换结果');
}
return base(op, input);
});
renderPage();
await card();
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(releaseRefresh).toBeTypeOf('function'));
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('还没有确认切换结果');
await within(dialog).findByText('上次操作的结果还需要确认。请关闭窗口,处理页面上的恢复提醒。');
fireEvent.click(within(dialog).getByRole('button', { name: '关闭' }));
expect(within(await card()).getByText('状态待确认')).toBeVisible();
await act(async () => releaseRefresh!());
expect(within(await card()).getByText('状态待确认')).toBeVisible();
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible();
expect(calls('routeChannelAccount')).toHaveLength(1);
});
it('keeps an uncertain operation locked when its recovery read is also unavailable', async () => {
const base = api.call.getMockImplementation()!;
api.recovery
.mockResolvedValueOnce({ recent: null, pending: [] })
.mockRejectedValue(new Error('恢复状态暂时不可用'));
api.call.mockImplementation(async (op, input) => {
if (op === 'routeChannelAccount') throw new Error('服务器响应还没有收到');
return base(op, input);
});
renderPage();
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('服务器响应还没有收到');
await waitFor(() => expect(within(dialog).getByRole('button', { name: '确认更换' })).toBeDisabled());
fireEvent.click(within(dialog).getByRole('button', { name: '关闭' }));
expect(within(await card()).getByText('状态待确认')).toBeVisible();
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(calls('routeChannelAccount')).toHaveLength(1);
});
it('blocks every new owner management write when server logout is pending without a local journal', async () => {
accounts = [
{ ...legacy, health: 'connected', sync_state: 'ready', blockers: ['wechat_disconnect_pending'] },
{
...spare,
target_agent_slug: agentB.slug,
target_agent_name: agentB.name,
health: 'login_required',
blockers: [],
},
];
renderPage();
const first = await card();
const second = await card(spare.display_name);
expect(within(first).getByText('状态待确认')).toBeVisible();
expect(within(first).queryByText('使用中')).not.toBeInTheDocument();
expect(
screen.getByText('请回到发起操作的设备确认原来的操作,再刷新这里;无需再次扫码或解除绑定。')
).toBeVisible();
expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled();
expect(within(first).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(within(first).getByRole('button', { name: '更多操作' })).toBeDisabled();
expect(within(second).getByRole('button', { name: '重新扫码' })).toBeDisabled();
expect(within(second).getByRole('button', { name: '更多操作' })).toBeDisabled();
expect(writes()).toEqual([]);
accounts[0].blockers = [];
fireEvent.click(screen.getByRole('button', { name: '刷新确认', exact: true }));
await waitFor(() => expect(within(first).getByRole('button', { name: '更换智能体' })).toBeEnabled());
expect(writes()).toEqual([]);
});
it.each(['switch', 'unbind'] as const)(
'blocks a %s dialog opened before another account logout becomes pending',
async (mode) => {
renderPage();
let dialog: HTMLElement;
if (mode === 'switch') {
dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
} else {
fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
}
accounts[1].blockers = ['wechat_disconnect_pending'];
fireEvent.click(
within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
);
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(
within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
).toBeDisabled();
expect(within(dialog).getByText(/请回到发起操作的设备确认原来的操作/)).toBeVisible();
expect(writes()).toEqual([]);
}
);
it('does not create an account when an already-open add dialog first reads server pending', async () => {
renderPage();
const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
await waitFor(() => expect(addButton).toBeEnabled());
fireEvent.click(addButton);
const dialog = screen.getByRole('dialog', { name: '添加微信' });
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
accounts[0].blockers = ['wechat_disconnect_pending'];
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('createChannelAccount')).toHaveLength(0);
expect(writes()).toEqual([]);
});
it('does not start a QR on another account after the preflight sees owner logout pending', async () => {
accounts[1] = {
...spare,
target_agent_slug: agentB.slug,
target_agent_name: agentB.name,
blockers: [],
};
renderPage();
const second = await card(spare.display_name);
accounts[0].blockers = ['wechat_disconnect_pending'];
fireEvent.click(within(second).getByRole('button', { name: '重新扫码' }));
const dialog = screen.getByRole('dialog', { name: '重新连接微信' });
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
});
it('does not auto-enable after QR confirmation if another account logout becomes pending', async () => {
const base = api.call.getMockImplementation()!;
qrStatus = 'confirmed';
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'channelAccountWechatBindStart') accounts[0].blockers = ['wechat_disconnect_pending'];
return result;
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
});
it('keeps original-operation recovery available while server logout is pending', async () => {
accounts[0].blockers = ['wechat_disconnect_pending'];
pending = [
{
id: 'original-logout',
operation: 'unbindChannelAccount',
input: { channel_account_id: legacy.id, operation_id: 'original-id', expected_revision: 7 },
created_at: '2026-10-13T00:00:00Z',
},
];
api.resolvePending.mockImplementation(async (id) => {
expect(id).toBe('original-logout');
pending = [];
Object.assign(accounts[0], {
enabled: false,
target_agent_slug: null,
target_agent_name: null,
health: 'login_required',
blockers: ['target_required'],
});
return { operation: 'unbindChannelAccount', result: { unbound: true, disconnected: true } };
});
renderPage();
await card();
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
const dialog = screen.getByRole('dialog', { name: '待处理记录' });
fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
await within(dialog).findByText('解除微信绑定的结果已确认。');
fireEvent.click(within(dialog).getByRole('button', { name: '返回列表' }));
expect(within(await card()).getByText('未绑定')).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledWith('original-logout', false);
expect(writes()).toEqual([]);
});
it.each(['worker_offline', 'channel_sync_degraded'])(
'preserves safe unbind for ordinary %s',
async (blocker) => {
accounts[0].blockers = [blocker];
if (blocker === 'channel_sync_degraded') accounts[0].sync_state = 'degraded';
renderPage();
const current = await more();
expect(within(current).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
}
);
it('uses known disabled-target metadata without blocking replacement or safe stopping', async () => {
agents[0] = { ...agentA, enabled: false };
renderPage();
const current = await card();
expect(within(current).getByText('智能体已停用')).toBeVisible();
expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
expect(within(current).getByText('当前智能体已停用,可以更换智能体。')).toBeVisible();
const menu = await more();
expect(within(menu).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
const dialog = await chooseExisting();
expect(within(dialog).queryByRole('radio', { name: agentA.name })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(within(await card()).getByText('使用中')).toBeVisible();
});
it('does not restore an old paused binding with a known disabled target', async () => {
agents[0] = { ...agentA, enabled: false };
accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
renderPage();
const current = await more();
expect(within(current).getByText('智能体已停用')).toBeVisible();
expect(within(current).queryByRole('button', { name: '恢复自动回复' })).not.toBeInTheDocument();
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('sends no mutation when the management preflight cannot read authoritative accounts', async () => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts') throw new Error('微信状态暂时无法读取');
return base(op, input);
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信状态暂时无法读取');
expect(writes()).toEqual([]);
});
it('sends no write when a superseded preflight observes pending after the newer account read fails', async () => {
renderPage();
await card();
const refreshButton = screen.getByRole('button', { name: '刷新微信列表' });
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
const base = api.call.getMockImplementation()!;
let accountReads = 0;
let finishPreflight: ((value: { items: CloudUserChannelAccount[] }) => void) | undefined;
api.call.mockImplementation(async (op, input) => {
if (op !== 'channelAccounts') return base(op, input);
accountReads += 1;
if (accountReads === 1)
return new Promise<{ items: CloudUserChannelAccount[] }>((resolve) => {
finishPreflight = resolve;
});
throw new Error('较新的微信状态读取失败');
});
act(() => {
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
fireEvent.click(refreshButton);
});
await waitFor(() => expect(accountReads).toBe(2));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('较新的微信状态读取失败');
await act(async () =>
finishPreflight!({
items: [{ ...legacy }, { ...spare, blockers: ['wechat_disconnect_pending'] }],
})
);
expect(within(dialog).getByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(writes()).toEqual([]);
});
const restorable = (changes: Partial<CloudUserChannelAccount> = {}): CloudUserChannelAccount => ({
...legacy,
enabled: false,
desired_state: 'paused',
blockers: ['binding_paused'],
...changes,
});
it('restores multiple valid old bindings serially with separate operation identities', async () => {
accounts = [
restorable(),
restorable({ id: spare.id, display_name: spare.display_name, revision: 2, target_agent_slug: agentB.slug, target_agent_name: agentB.name, target_agent_published_version: 4 }),
];
const base = api.call.getMockImplementation()!;
let releaseFirst: (() => void) | undefined;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount' && input.channel_account_id === legacy.id)
await new Promise<void>((resolve) => { releaseFirst = resolve; });
return base(op, input);
});
renderPage();
await waitFor(() => expect(calls('enableChannelAccount')).toHaveLength(1));
expect(accounts.every((account) => !account.enabled)).toBe(true);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, expected_revision: 7 });
await act(async () => releaseFirst!());
await waitFor(() => expect(calls('enableChannelAccount')).toHaveLength(2));
await waitFor(() => expect(accounts.every((account) => account.enabled)).toBe(true));
expect(calls('enableChannelAccount')[1][1]).toMatchObject({ channel_account_id: spare.id, expected_revision: 2 });
expect(calls('enableChannelAccount')[0][1].operation_id).not.toBe(calls('enableChannelAccount')[1][1].operation_id);
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it.each([
['offline Worker', { worker_online: false, blockers: ['worker_offline', 'binding_paused'] }],
['missing Worker evidence', { worker_online: undefined }],
['login required', { health: 'login_required' }],
['unknown login', { health: 'unknown' }],
['degraded synchronization', { sync_state: 'degraded', blockers: ['channel_sync_degraded'] }],
['missing synchronization evidence', { sync_state: undefined }],
['server logout pending', { blockers: ['wechat_disconnect_pending'] }],
['unknown blocker', { blockers: ['unrecognized_fixture_blocker'] }],
['missing target', { target_agent_slug: null }],
] satisfies [string, Partial<CloudUserChannelAccount>][])(
'never restores a disabled binding with %s',
async (_reason, changes) => {
accounts = [restorable(changes)];
await act(async () => renderPage());
expect(writes()).toEqual([]);
expect(accounts[0].enabled).toBe(false);
}
);
it.each(['disabled', 'unpublished', 'missing', 'archived'] as const)(
'never restores a binding whose authoritative target is %s',
async (state) => {
accounts = [restorable()];
agents = state === 'missing' ? [agentB] : [{ ...agentA, enabled: state !== 'disabled', archived: state === 'archived', published_version: state === 'unpublished' ? null : 2 }];
await act(async () => renderPage());
expect(writes()).toEqual([]);
}
);
it.each(['accounts', 'catalog', 'recovery'] as const)(
'sends no restoration write when the fresh %s read fails',
async (source) => {
accounts = [restorable()];
const base = api.call.getMockImplementation()!;
if (source === 'accounts') api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length > 1) throw new Error('恢复前微信状态读取失败');
return base(op, input);
});
if (source === 'catalog') api.list.mockResolvedValueOnce({ agents, next_cursor: null }).mockRejectedValue(new Error('恢复前智能体列表读取失败'));
if (source === 'recovery') api.recovery.mockResolvedValueOnce({ recent: null, pending: [] }).mockRejectedValue(new Error('恢复前原操作读取失败'));
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('读取失败');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')).toHaveLength(0);
}
);
it('keeps a global original pause journal untouched and blocks restoration of every account', async () => {
accounts = [restorable(), restorable({ id: spare.id, display_name: spare.display_name })];
const original = {
id: 'original-pause', operation: 'pauseChannelAccount',
input: { channel_account_id: spare.id, expected_revision: 2, operation_id: 'original-pause-id' },
created_at: '',
};
pending = [original];
await act(async () => renderPage());
expect(writes()).toEqual([]);
expect(pending).toEqual([original]);
expect(api.resolvePending).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible();
});
it.each(['journal', 'target', 'logout', 'disabled target'] as const)(
'denies restoration when fresh preflight discovers %s changes',
async (race) => {
accounts = [restorable(), { ...spare }];
const base = api.call.getMockImplementation()!;
if (race === 'journal') api.recovery.mockResolvedValueOnce({ recent: null, pending: [] }).mockResolvedValue({ recent: null, pending: [{ id: 'new-original-pause', operation: 'pauseChannelAccount', input: { channel_account_id: spare.id, operation_id: 'fresh-pause-id', expected_revision: 2 }, created_at: '' }] });
if (race === 'disabled target') api.list.mockResolvedValueOnce({ agents, next_cursor: null }).mockResolvedValue({ agents: [{ ...agentA, enabled: false }, agentB], next_cursor: null });
if (race === 'target' || race === 'logout') api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3) {
if (race === 'target') accounts[0].target_agent_slug = agentB.slug;
else accounts[1].blockers = ['wechat_disconnect_pending'];
}
return base(op, input);
});
await act(async () => renderPage());
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(api.resolvePending).not.toHaveBeenCalled();
}
);
it('stops after an ambiguous restore and resolves only its original identity through recovery', async () => {
accounts = [restorable(), restorable({ id: spare.id, display_name: spare.display_name, revision: 2 })];
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount' && input.channel_account_id === legacy.id) {
pending = [{ id: 'ambiguous-enable', operation: op, input: { ...input }, created_at: '' }];
throw new Error('启用结果尚未确认');
}
return base(op, input);
});
api.resolvePending.mockImplementation(async (id) => {
expect(id).toBe('ambiguous-enable');
expect(pending[0].input).toEqual(calls('enableChannelAccount')[0][1]);
Object.assign(accounts[0], { enabled: true, desired_state: 'enabled', blockers: [] });
pending = [];
return { operation: 'enableChannelAccount', result: { channel: { ...accounts[0] } } };
});
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('启用结果尚未确认');
expect(calls('enableChannelAccount')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible());
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(accounts[1].enabled).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
const recovery = screen.getByRole('dialog', { name: '待处理记录' });
fireEvent.click(within(recovery).getByRole('button', { name: '再试一次' }));
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledWith('ambiguous-enable', false));
expect(calls('enableChannelAccount').filter(([, input]) => input.channel_account_id === legacy.id)).toHaveLength(1);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it('does not create a restore write after unmount during the fresh catalog read', async () => {
accounts = [restorable()];
let releaseCatalog: (() => void) | undefined;
api.list.mockResolvedValueOnce({ agents, next_cursor: null }).mockImplementation(async () => {
await new Promise<void>((resolve) => { releaseCatalog = resolve; });
return { agents, next_cursor: null };
});
const view = renderPage();
await waitFor(() => expect(releaseCatalog).toBeTypeOf('function'));
view.unmount();
await act(async () => releaseCatalog!());
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('does not restore while a deep-linked target choice is open', async () => {
accounts = [restorable()];
renderPage('/cloud-agents/channels?account=' + legacy.id + '&target=' + agentB.slug);
const dialog = await screen.findByRole('dialog', { name: '更换智能体' });
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked();
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')).toHaveLength(0);
});
it('detects Worker recovery on the visible page and restores replies once without a QR scan', async () => {
accounts = [restorable({ worker_online: false, blockers: ['worker_offline', 'binding_paused'] })];
vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
await screen.findByText('微信服务离线');
accounts[0].worker_online = true;
accounts[0].blockers = ['binding_paused'];
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(await screen.findByText('使用中')).toBeVisible();
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
});
it.each(['pauseChannelBinding', 'wechatUnbind'] as const)(
'blocks legacy restoration while the original %s journal is pending',
async (operation) => {
accounts = [restorable()];
pending = [{ id: 'legacy-original', operation, input: { channel_account_id: legacy.id, expected_revision: 7, operation_id: 'legacy-original-id' }, created_at: '' }];
await act(async () => renderPage());
expect(writes()).toEqual([]);
expect(api.resolvePending).not.toHaveBeenCalled();
expect(pending[0].input.operation_id).toBe('legacy-original-id');
expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible();
}
);
it('rechecks the same revision after preflight briefly finds an offline Worker and restores on later recovery', async () => {
accounts = [restorable()];
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3)
Object.assign(accounts[0], { worker_online: false, blockers: ['worker_offline', 'binding_paused'] });
return base(op, input);
});
vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
await screen.findByText('微信服务离线');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(accounts[0].revision).toBe(7);
accounts[0].worker_online = true;
accounts[0].blockers = ['binding_paused'];
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 7 });
expect(await screen.findByText('使用中')).toBeVisible();
});
it('keeps a failed restoration stopped until explicit refresh and reuses its enable operation identity', async () => {
accounts = [restorable()];
const base = api.call.getMockImplementation()!;
let attempts = 0;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount' && ++attempts === 1) throw new Error('启用暂时失败');
return base(op, input);
});
vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('启用暂时失败');
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
expect(await screen.findByText('使用中')).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(2);
expect(calls('enableChannelAccount')[0][1]).toEqual(calls('enableChannelAccount')[1][1]);
});
it('keeps a preflight target change stopped through polling and restores only after explicit refresh', async () => {
accounts = [restorable()];
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3)
Object.assign(accounts[0], { target_agent_slug: agentB.slug, target_agent_name: agentB.name, target_agent_published_version: 4, revision: 8 });
return base(op, input);
});
vi.useFakeTimers({ shouldAdvanceTime: true });
await act(async () => renderPage());
expect(calls('enableChannelAccount')).toHaveLength(0);
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
expect(calls('enableChannelAccount')).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
expect(await screen.findByText('使用中')).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 8 });
expect(accounts[0].target_agent_slug).toBe(agentB.slug);
expect(calls('routeChannelAccount')).toHaveLength(0);
});
const removedCardKey = (userId = 'creator', tenantId: string | null = null) =>
'makelore.wechat-removed-cards:' + JSON.stringify([tenantId, userId]);
async function deletingUnboundCard() {
const current = await more(spare.display_name);
const remove = within(current).getByRole('button', { name: '删除微信卡片' });
await waitFor(() => expect(remove).toBeEnabled());
fireEvent.click(remove);
return screen.getByRole('dialog', { name: '删除微信卡片' });
}
it('offers device-only deletion for an unbound card and cancelling changes neither storage nor server state', async () => {
renderPage();
const dialog = await deletingUnboundCard();
expect(within(dialog).getByText(/其他设备的记录和聊天历史保留/)).toBeVisible();
expect(within(dialog).getByText(/以后重新绑定时会再次显示/)).toBeVisible();
expect(localStorage.getItem(removedCardKey())).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
expect(localStorage.getItem(removedCardKey())).toBeNull();
});
it('removes only the confirmed unbound card on this device and persists through refresh and remount', async () => {
const view = renderPage();
const dialog = await deletingUnboundCard();
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await screen.findByText('微信卡片已从这台设备移除,聊天历史保留。')).toBeVisible();
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
expect(await card()).toBeVisible();
expect(JSON.parse(localStorage.getItem(removedCardKey())!)).toEqual({ [spare.id]: spare.revision });
expect(accounts.find((account) => account.id === spare.id)).toEqual(spare);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(calls('channelAccounts').length).toBeGreaterThanOrEqual(4));
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
view.unmount();
renderPage();
await card();
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
expect(writes()).toEqual([]);
expect(api.resolvePending).not.toHaveBeenCalled();
});
it.each([
['another account', 'another-creator', null],
['another tenant', 'creator', 'another-tenant'],
] as const)('isolates removed cards from %s on the same device', async (_scope, userId, tenantId) => {
localStorage.setItem(removedCardKey(), JSON.stringify({ [spare.id]: spare.revision }));
useAuthStore.setState({ user: { username: userId, userId, tenantId, deptId: null, authorities: [] } });
renderPage();
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
});
it.each(['rebound', 'new revision'] as const)(
'shows a locally removed card again when authoritative state is %s',
async (change) => {
localStorage.setItem(removedCardKey(), JSON.stringify({ [spare.id]: spare.revision }));
renderPage();
await card();
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
if (change === 'rebound') Object.assign(accounts[1], { target_agent_slug: agentB.slug, target_agent_name: agentB.name, health: 'connected', enabled: true, desired_state: 'enabled', blockers: [] });
else accounts[1].revision += 1;
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
const current = await card(spare.display_name);
expect(within(current).getByText(change === 'rebound' ? agentB.name : '未绑定')).toBeVisible();
expect(writes()).toEqual([]);
}
);
it('keeps the card and shows a visible error when device storage rejects deletion', async () => {
renderPage();
const dialog = await deletingUnboundCard();
vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('fixture storage unavailable'); });
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('卡片没有删除:暂时无法保存到这台设备,请重试。');
expect(localStorage.getItem(removedCardKey())).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
});
it.each(['rebound', 'new revision', 'logout pending', 'unknown login'] as const)(
'denies device deletion when a fresh account read discovers %s',
async (race) => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await deletingUnboundCard();
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts') {
if (race === 'rebound') Object.assign(accounts[1], { target_agent_slug: agentB.slug, health: 'connected', enabled: true, desired_state: 'enabled', blockers: [] });
if (race === 'new revision') accounts[1].revision += 1;
if (race === 'logout pending') accounts[0].blockers = ['wechat_disconnect_pending'];
if (race === 'unknown login') accounts[1].health = 'unknown';
}
return base(op, input);
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(race === 'logout pending' ? '微信退出还在确认中' : '这个微信的状态已变化');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
}
);
it('denies device deletion if fresh recovery discovers an original legacy WeChat operation', async () => {
renderPage();
const dialog = await deletingUnboundCard();
pending = [{ id: 'delete-racing-old-operation', operation: 'wechatUnbind', input: { channel_account_id: spare.id, operation_id: 'original-delete-race', expected_revision: spare.revision }, created_at: '' }];
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('上次微信操作还没有确认');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('denies device deletion if the authoritative account read becomes unavailable', async () => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await deletingUnboundCard();
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts') throw new Error('删除前微信状态无法读取');
return base(op, input);
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('删除前微信状态无法读取');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
});
it.each([
['account', 'another-creator', null],
['tenant', 'creator', 'another-tenant'],
] as const)('loads the changed %s removal preference without remounting the page', async (_scope, userId, tenantId) => {
localStorage.setItem(removedCardKey(userId, tenantId), JSON.stringify({ [spare.id]: spare.revision }));
renderPage();
expect(await card(spare.display_name)).toBeVisible();
await act(async () => useAuthStore.setState({ user: { username: userId, userId, tenantId, deptId: null, authorities: [] } }));
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
await act(async () => useAuthStore.setState({ user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] } }));
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
});
it.each(['connected', 'unknown'] as const)(
'keeps safe logout available before deleting an unbound card whose login is %s',
async (health) => {
accounts[1] = { ...spare, health };
renderPage();
const current = await more(spare.display_name);
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
expect(within(current).getByRole('button', { name: '删除微信卡片' })).toBeDisabled();
expect(writes()).toEqual([]);
}
);
it('denies device deletion when fresh original-operation recovery cannot be read', async () => {
renderPage();
const dialog = await deletingUnboundCard();
api.recovery.mockRejectedValue(new Error('删除前原操作无法确认'));
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('删除前原操作无法确认');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
});
it('does not store deletion under the old owner when the account changes during its fresh read', async () => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await deletingUnboundCard();
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3)
useAuthStore.setState({ user: { username: 'another-creator', userId: 'another-creator', tenantId: null, deptId: null, authorities: [] } });
return base(op, input);
});
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' })));
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(localStorage.getItem(removedCardKey('another-creator'))).toBeNull();
expect(writes()).toEqual([]);
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
});