feat: add user-level WeChat channel accounts
This commit is contained in:
68
tests/unit/channel-accounts-page.test.tsx
Normal file
68
tests/unit/channel-accounts-page.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts';
|
||||
|
||||
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 = { slug: 'ml-' + 'a'.repeat(32), name: '写作助手', published_version: 2, draft_revision: 3 };
|
||||
const agentB = { slug: 'ml-' + 'b'.repeat(32), name: '资料助手', published_version: 4, draft_revision: 5 };
|
||||
const legacy = {
|
||||
id: 'wechat-legacy', display_name: '原有微信', binding_id: 'binding-legacy', revision: 7, enabled: true,
|
||||
status: 'active', health: 'connected', blockers: [], target_agent_slug: agentA.slug, target_agent_name: agentA.name,
|
||||
target_agent_published_version: 2, provider_generation: 'generation-legacy', access_mode: 'self_only', policy_revision: 1,
|
||||
};
|
||||
const spare = {
|
||||
id: 'wechat-spare', display_name: '备用微信', binding_id: 'binding-spare', 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, provider_generation: 'generation-spare', access_mode: 'self_only', policy_revision: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.list.mockResolvedValue({ agents: [agentA, agentB], next_cursor: null });
|
||||
api.recovery.mockResolvedValue({ recent: null, pending: [] });
|
||||
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
|
||||
if (operation === 'channelAccounts') return { items: [legacy, spare] };
|
||||
if (operation === 'channelAccountWechatBindStart') return { session_key: 'qr-spare', status: 'verification_required', qrcode_url: 'https://login.example.test/qr' };
|
||||
if (operation === 'routeChannelAccount') return { channel: { ...spare, target_agent_slug: input.target_agent_slug } };
|
||||
if (operation === 'channelBindings') return { items: [{ ...legacy, address: 'wechat:legacy', target_agent_address: 'agent:legacy', route_revision: 7, worker_online: true, agent_slug: agentA.slug, published_version: 2, desired_state: 'enabled', sync_state: 'ready' }], available_accounts: [] };
|
||||
if (operation === 'channelCallers') return { items: [] };
|
||||
throw new Error(`Unexpected operation ${operation}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows legacy and unassigned accounts and can connect before choosing an Agent', async () => {
|
||||
render(<MemoryRouter initialEntries={['/cloud-agents/channels?account=wechat-spare']}><ChannelAccounts /></MemoryRouter>);
|
||||
expect(await screen.findByText('原有微信')).toBeVisible();
|
||||
expect(screen.getAllByText('备用微信')).toHaveLength(2);
|
||||
const detail = await screen.findByRole('article', { name: '备用微信账号详情' });
|
||||
expect(within(detail).getByText('尚未选择目标智能体')).toBeVisible();
|
||||
fireEvent.click(within(detail).getByRole('button', { name: '扫码连接' }));
|
||||
expect(await within(detail).findByRole('img', { name: '个人微信登录二维码' })).toBeVisible();
|
||||
expect(api.call).toHaveBeenCalledWith('channelAccountWechatBindStart', expect.objectContaining({ channel_account_id: 'wechat-spare' }));
|
||||
});
|
||||
|
||||
it('routes two independent accounts to the same Agent without starting a new QR login', async () => {
|
||||
render(<MemoryRouter initialEntries={['/cloud-agents/channels?account=wechat-spare']}><ChannelAccounts /></MemoryRouter>);
|
||||
const spareDetail = await screen.findByRole('article', { name: '备用微信账号详情' });
|
||||
fireEvent.change(within(spareDetail).getByLabelText('目标智能体'), { target: { value: agentA.slug } });
|
||||
fireEvent.click(within(spareDetail).getByRole('button', { name: '保存目标' }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('routeChannelAccount', expect.objectContaining({
|
||||
channel_account_id: 'wechat-spare', target_agent_slug: agentA.slug, enabled: false,
|
||||
})));
|
||||
expect(api.call.mock.calls.some(([operation]) => operation === 'channelAccountWechatBindStart')).toBe(false);
|
||||
});
|
||||
|
||||
it('switches an enabled legacy account target while preserving its enabled connection', async () => {
|
||||
render(<MemoryRouter initialEntries={['/cloud-agents/channels?account=wechat-legacy']}><ChannelAccounts /></MemoryRouter>);
|
||||
const detail = await screen.findByRole('article', { name: '原有微信账号详情' });
|
||||
fireEvent.change(within(detail).getByLabelText('目标智能体'), { target: { value: agentB.slug } });
|
||||
fireEvent.click(within(detail).getByRole('button', { name: '保存目标' }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('routeChannelAccount', expect.objectContaining({
|
||||
channel_account_id: 'wechat-legacy', target_agent_slug: agentB.slug, expected_revision: 7, enabled: true,
|
||||
})));
|
||||
expect(api.call.mock.calls.some(([operation]) => String(operation).includes('WechatBindStart'))).toBe(false);
|
||||
});
|
||||
@@ -201,6 +201,24 @@ describe('Main cloud Agents boundary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('routes user-level channel accounts without requiring an Agent slug', () => {
|
||||
const list = operationPlan({ operation: 'channelAccounts', input: {} });
|
||||
expect(list.path).toBe('/api/makelore/channel-accounts');
|
||||
expect(list.project({ items: [{ id: 'wechat-1', display_name: '工作微信', revision: 2, enabled: false, status: 'paused', health: 'connected',
|
||||
blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, target_agent_published_version: null, access_token: 'private' }] })).toEqual({
|
||||
items: [{ id: 'wechat-1', display_name: '工作微信', revision: 2, enabled: false, status: 'paused', health: 'connected',
|
||||
blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, target_agent_published_version: null }],
|
||||
});
|
||||
const route = operationPlan({ operation: 'routeChannelAccount', input: { channel_account_id: 'wechat-1', operation_id: 'op-route',
|
||||
target_agent_slug: draft.slug, expected_revision: 2, enabled: true } });
|
||||
expect(route.path).toBe('/api/makelore/channel-accounts/wechat-1/route');
|
||||
expect(route.body).toEqual({ operation_id: 'op-route', target_agent_slug: draft.slug, expected_revision: 2, enabled: true });
|
||||
expect(() => operationPlan({ operation: 'routeChannelAccount', input: { channel_account_id: 'wechat-1', operation_id: 'op-route',
|
||||
target_agent_slug: null, expected_revision: 2, enabled: true } })).toThrow('invalid_input');
|
||||
expect(operationPlan({ operation: 'channelAccountOperation', input: { operation_id: 'op-route' } }).path)
|
||||
.toBe('/api/makelore/channel-operations/op-route');
|
||||
});
|
||||
|
||||
it('recovers a completed WeChat verification from its receipt without replaying the code', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
||||
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
|
||||
@@ -216,6 +234,19 @@ describe('Main cloud Agents boundary', () => {
|
||||
expect(fetchImpl.mock.calls[2][0]).toContain(`/api/makelore/channel-operations/${input.operation_id}?agent_slug=${draft.slug}`);
|
||||
});
|
||||
|
||||
it('recovers user-level WeChat verification without an Agent slug', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
||||
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
|
||||
const module = moduleFor(fetchImpl);
|
||||
const verification = { channel_account_id: 'channel-1', operation_id: input.operation_id, session_key: 'session-1', verify_code: 'secret-code-123' };
|
||||
await expect(module.execute({ operation: 'channelAccountWechatBindVerification', input: verification })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
||||
const pending = (await module.recovery()).pending[0];
|
||||
expect(pending.input).not.toHaveProperty('verify_code');
|
||||
expect(await module.resolvePending({ id: pending.id })).toMatchObject({ operation: 'channelAccountWechatBindVerification', result: { status: 'completed' } });
|
||||
expect(fetchImpl.mock.calls[2][0]).toContain(`/api/makelore/channel-operations/${input.operation_id}`);
|
||||
expect(fetchImpl.mock.calls[2][0]).not.toContain('agent_slug');
|
||||
});
|
||||
|
||||
it('asks for a new WeChat verification code only when its receipt is absent', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
||||
.mockResolvedValueOnce(json({ detail: { code: 'operation_not_found' } }, 404));
|
||||
|
||||
Reference in New Issue
Block a user