1573 lines
76 KiB
TypeScript
1573 lines
76 KiB
TypeScript
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();
|
||
});
|