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: () => 个人微信登录二维码, })); 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 {location.pathname + location.search}; } function renderPage(entry = '/cloud-agents/channels') { return render( } /> 智能体列表} /> ); } 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) => { 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((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((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((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 => ({ ...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((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][])( '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((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(); });