Files
zn-ai/tests/channels.test.ts
duanshuwen 18f12d6ce3 feat: enhance channel configuration UI and validation
- Updated ChannelInstructionsPanel to include a button for viewing documentation, improving user guidance.
- Enhanced ChannelTokenField to support showing/hiding secret values with appropriate labels and icons.
- Refined ChannelTypeSelector to display connection type icons and improved layout for better user experience.
- Added new messages for documentation links, validation feedback, and secret management in i18n.
- Extended ChannelMeta to include optional documentation URLs for better context on configuration fields.
- Implemented credential validation logic in ChannelsPage to ensure user inputs are validated before saving.
- Introduced ChannelLogo component to display channel icons in the UI.
- Added tests for channel credential validation to ensure proper error handling and feedback.
2026-04-19 16:43:07 +08:00

441 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
configGet: vi.fn(),
listStoredChannelAccountRecords: vi.fn(),
listCronJobs: vi.fn(),
listAgentsSnapshot: vi.fn(),
assignChannelToAgent: vi.fn(),
clearAllChannelBindings: vi.fn(),
clearChannelBinding: vi.fn(),
listAgentsSnapshotResult: {
agents: [],
channelOwners: {},
channelAccountOwners: {},
},
}));
vi.mock('@electron/service/config-service', () => ({
default: {
get: mocks.configGet,
},
configManager: {
get: mocks.configGet,
},
}));
vi.mock('../electron/utils/cron-store', () => ({
listCronJobs: mocks.listCronJobs,
}));
vi.mock('../electron/utils/channel-config', () => ({
listStoredChannelAccountRecords: mocks.listStoredChannelAccountRecords,
}));
vi.mock('../electron/utils/agent-config', () => ({
assignChannelToAgent: mocks.assignChannelToAgent,
clearAllChannelBindings: mocks.clearAllChannelBindings,
clearChannelBinding: mocks.clearChannelBinding,
listAgentsSnapshot: mocks.listAgentsSnapshot,
}));
import {
getSelectedChannelsConfig,
listSelectedChannelAccountGroups,
listSelectedChannelTargets,
} from '../electron/utils/channels';
import { normalizeRequest } from '../electron/api/route-utils';
import { handleChannelRoutes } from '../electron/api/routes/channels';
function createRouteContext() {
const notifyRuntimeChanged = vi.fn();
return {
gatewayManager: {
notifyRuntimeChanged,
},
providerApiService: {
getAccounts: () => [],
getDefault: () => ({ accountId: 'default' }),
},
mainWindow: null,
notifyRuntimeChanged,
} as any;
}
describe('channels utilities', () => {
beforeEach(() => {
mocks.configGet.mockReset();
mocks.listStoredChannelAccountRecords.mockReset();
mocks.listCronJobs.mockReset();
mocks.listAgentsSnapshot.mockReset();
mocks.assignChannelToAgent.mockReset();
mocks.clearAllChannelBindings.mockReset();
mocks.clearChannelBinding.mockReset();
mocks.listAgentsSnapshot.mockReturnValue(mocks.listAgentsSnapshotResult);
});
it('normalizes and groups selected channels by inferred channel type', () => {
mocks.configGet.mockReturnValue([
{
id: ' acct-1 ',
channelName: ' 抖音 ',
channelUrl: ' https://life.douyin.com/live ',
},
{
id: 'acct-1',
channelName: '抖音直播',
channelUrl: 'https://douyin.com/duplicate',
},
{
id: 'alpha',
channelName: 'Alpha Store',
channelUrl: 'https://meituan.com/a',
},
{
id: 'beta',
channelName: 'Beta Store',
channelUrl: 'https://me.meituan.com/b',
},
]);
expect(getSelectedChannelsConfig()).toEqual([
{
id: 'acct-1',
channelName: '抖音',
channelUrl: 'https://life.douyin.com/live',
},
{
id: 'alpha',
channelName: 'Alpha Store',
channelUrl: 'https://meituan.com/a',
},
{
id: 'beta',
channelName: 'Beta Store',
channelUrl: 'https://me.meituan.com/b',
},
]);
mocks.listStoredChannelAccountRecords.mockReturnValue([
{
channelType: 'douyin',
channelLabel: '抖音',
defaultAccountId: 'acct-1',
channelEnabled: true,
accountId: 'acct-1',
accountName: '抖音',
accountEnabled: true,
channelUrl: 'https://life.douyin.com/live',
config: {},
metadata: {},
},
{
channelType: 'meituan',
channelLabel: 'Alpha Store',
defaultAccountId: 'alpha',
channelEnabled: true,
accountId: 'alpha',
accountName: 'Alpha Store',
accountEnabled: true,
channelUrl: 'https://meituan.com/a',
config: {},
metadata: {},
},
{
channelType: 'meituan',
channelLabel: 'Alpha Store',
defaultAccountId: 'alpha',
channelEnabled: true,
accountId: 'beta',
accountName: 'Beta Store',
accountEnabled: true,
channelUrl: 'https://me.meituan.com/b',
config: {},
metadata: {},
},
]);
const groups = listSelectedChannelAccountGroups({
agents: [
{ id: 'Agent-A', name: 'Ada' },
{ id: 'agent-b', name: 'Ben' },
],
channelOwners: {
meituan: 'Agent-A',
},
channelAccountOwners: {
'meituan:alpha': 'agent-b',
},
});
const byType = Object.fromEntries(groups.map((group) => [group.channelType, group]));
expect(byType.douyin).toMatchObject({
channelType: 'douyin',
channelLabel: '抖音',
defaultAccountId: 'acct-1',
status: 'degraded',
accounts: [
{
accountId: 'acct-1',
name: '抖音',
configured: true,
status: 'degraded',
isDefault: true,
channelUrl: 'https://life.douyin.com/live',
},
],
});
expect(byType.meituan).toMatchObject({
channelType: 'meituan',
channelLabel: 'Alpha Store',
defaultAccountId: 'alpha',
status: 'connected',
accounts: [
{
accountId: 'alpha',
name: 'Alpha Store',
configured: true,
status: 'connected',
isDefault: true,
agentId: 'agent-b',
bindingScope: 'account',
channelUrl: 'https://meituan.com/a',
},
{
accountId: 'beta',
name: 'Beta Store',
configured: true,
status: 'connected',
isDefault: false,
agentId: 'agent-a',
bindingScope: 'channel',
channelUrl: 'https://me.meituan.com/b',
},
],
});
});
it('builds channel targets from account data, URL hints, and cron history', () => {
mocks.listStoredChannelAccountRecords.mockReturnValue([
{
id: 'acct-1',
channelType: 'douyin',
channelLabel: '抖音直播间',
defaultAccountId: 'acct-1',
channelEnabled: true,
accountId: 'acct-1',
accountName: '抖音直播间',
accountEnabled: true,
channelUrl: 'https://webhook.example.com/send?roomId=ROOM-9#panel?threadId=TH-1',
config: {},
metadata: {},
},
]);
mocks.listCronJobs.mockReturnValue([
{
id: 'cron-1',
name: 'nightly announce',
message: 'hello',
schedule: '* * * * *',
enabled: true,
createdAt: '2026-04-18T00:00:00.000Z',
updatedAt: '2026-04-18T00:00:00.000Z',
delivery: {
mode: 'announce',
channel: 'douyin',
accountId: 'acct-1',
to: 'https://history.example.com/announce',
},
},
{
id: 'cron-2',
name: 'ignored',
message: 'skip me',
schedule: '* * * * *',
enabled: true,
createdAt: '2026-04-18T00:00:00.000Z',
updatedAt: '2026-04-18T00:00:00.000Z',
delivery: {
mode: 'announce',
channel: 'fliggy',
to: 'should-not-appear',
},
},
]);
const targets = listSelectedChannelTargets('douyin', 'acct-1');
expect(targets[0]).toMatchObject({
value: '抖音直播间',
kind: 'name',
source: 'channel-name',
});
expect(targets).toContainEqual(expect.objectContaining({
value: 'acct-1',
label: '账号 ID · acct-1',
kind: 'identifier',
source: 'account-id',
channelType: 'douyin',
accountId: 'acct-1',
}));
expect(targets).toContainEqual(expect.objectContaining({
value: 'ROOM-9',
label: 'Room ID · ROOM-9',
kind: 'identifier',
source: 'query-param',
channelType: 'douyin',
accountId: 'acct-1',
}));
expect(targets).toContainEqual(expect.objectContaining({
value: 'TH-1',
label: 'Thread ID · TH-1',
kind: 'identifier',
source: 'hash-param',
channelType: 'douyin',
accountId: 'acct-1',
}));
expect(targets).toContainEqual(expect.objectContaining({
kind: 'webhook',
source: 'channel-url',
channelType: 'douyin',
accountId: 'acct-1',
}));
expect(targets).toContainEqual(expect.objectContaining({
value: 'https://history.example.com/announce',
kind: 'webhook',
source: 'fallback',
channelType: 'douyin',
accountId: 'acct-1',
}));
const filtered = listSelectedChannelTargets('douyin', 'acct-1', 'history');
expect(filtered).toHaveLength(1);
expect(filtered[0]).toMatchObject({
value: 'https://history.example.com/announce',
});
});
});
describe('channel credential validation route', () => {
it('returns a valid result with warnings for a token/password/url payload', async () => {
const ctx = createRouteContext();
const response = await handleChannelRoutes(normalizeRequest({
path: '/api/channels/credentials/validate',
method: 'POST',
body: JSON.stringify({
channelType: 'imessage',
accountId: 'acct-1',
credentials: {
serverUrl: 'http://bridge.example.com',
password: 'bridge-password',
},
}),
}), ctx);
expect(response?.ok).toBe(true);
expect(response?.json).toMatchObject({
success: true,
valid: true,
errors: [],
warnings: ['Server URL 使用了不安全的 http URL建议改用 https'],
details: expect.objectContaining({
channelType: 'imessage',
accountId: 'acct-1',
connectionType: 'token',
}),
});
expect(response?.json).toMatchObject({
details: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
key: 'serverUrl',
kind: 'url',
provided: true,
valid: true,
warnings: ['Server URL 使用了不安全的 http URL建议改用 https'],
}),
expect.objectContaining({
key: 'password',
kind: 'password',
provided: true,
valid: true,
}),
]),
}),
});
expect(ctx.notifyRuntimeChanged).not.toHaveBeenCalled();
});
it('returns field errors for missing token and text credentials', async () => {
const ctx = createRouteContext();
const response = await handleChannelRoutes(normalizeRequest({
path: '/api/channels/credentials/validate',
method: 'POST',
body: JSON.stringify({
channelType: 'discord',
credentials: {
token: ' ',
guildId: '',
channelId: '123456789012345678',
},
}),
}), ctx);
expect(response?.ok).toBe(true);
expect(response?.json).toMatchObject({
success: true,
valid: false,
warnings: [],
errors: [
'Bot Token is required',
'Guild ID is required',
],
details: expect.objectContaining({
channelType: 'discord',
connectionType: 'token',
}),
});
expect(response?.json).toMatchObject({
details: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
key: 'token',
kind: 'password',
provided: false,
valid: false,
errors: ['Bot Token is required'],
}),
expect.objectContaining({
key: 'guildId',
kind: 'text',
provided: false,
valid: false,
errors: ['Guild ID is required'],
}),
expect.objectContaining({
key: 'channelId',
kind: 'text',
provided: true,
valid: true,
}),
]),
}),
});
expect(ctx.notifyRuntimeChanged).not.toHaveBeenCalled();
});
});