539 lines
15 KiB
TypeScript
539 lines
15 KiB
TypeScript
/* @vitest-environment happy-dom */
|
|
|
|
/* eslint-disable vue/one-component-per-file */
|
|
|
|
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
|
|
|
import { flushPromises, mount } from '@vue/test-utils';
|
|
import { defineComponent, h } from 'vue';
|
|
|
|
import MessageSubscriptionModal from '@test-source/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const STUN_SOURCE_KEY = 'network.stun.mapping-port-changed';
|
|
const GENERIC_SOURCE_KEY = 'system.health.changed';
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const state: any = {
|
|
create: vi.fn(),
|
|
formApi: {
|
|
getValues: vi.fn(async () => state.values),
|
|
resetForm: vi.fn(async () => {}),
|
|
resetValidate: vi.fn(async () => {}),
|
|
setState: vi.fn((patch) => {
|
|
if (patch.schema) state.formOptions.schema = patch.schema;
|
|
}),
|
|
setValues: vi.fn(async (patch) => {
|
|
Object.assign(state.values, patch);
|
|
}),
|
|
validate: vi.fn(async () => ({ valid: true })),
|
|
},
|
|
formOptions: undefined,
|
|
getOptions: vi.fn(),
|
|
modalApi: {
|
|
close: vi.fn(async () => {}),
|
|
getData: vi.fn(),
|
|
lock: vi.fn(),
|
|
open: vi.fn(),
|
|
setData: vi.fn(),
|
|
unlock: vi.fn(),
|
|
},
|
|
modalOptions: undefined,
|
|
update: vi.fn(),
|
|
values: {},
|
|
};
|
|
return state;
|
|
});
|
|
|
|
/** 创建可链式调用的表单校验规则替身。 */
|
|
function createRule(): any {
|
|
const rule: any = {};
|
|
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
|
|
rule[method] = vi.fn(() => rule);
|
|
}
|
|
return rule;
|
|
}
|
|
|
|
vi.mock('#/adapter/form', () => ({
|
|
useVbenForm: vi.fn((options) => {
|
|
mocks.formOptions = options;
|
|
const Form = defineComponent({
|
|
name: 'MockForm',
|
|
setup() {
|
|
return () => h('form', { 'data-testid': 'subscription-form' });
|
|
},
|
|
});
|
|
return [Form, mocks.formApi];
|
|
}),
|
|
z: {
|
|
literal: vi.fn(createRule),
|
|
string: vi.fn(createRule),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@vben/common-ui', () => ({
|
|
useVbenModal: vi.fn((options) => {
|
|
mocks.modalOptions = options;
|
|
const Modal = defineComponent({
|
|
name: 'MockModal',
|
|
setup(_, { slots }) {
|
|
return () => h('section', slots.default?.());
|
|
},
|
|
});
|
|
return [Modal, mocks.modalApi];
|
|
}),
|
|
}));
|
|
|
|
vi.mock('#/api/qqbot/message-push', () => ({
|
|
createMessageSubscription: mocks.create,
|
|
getMessagePushSourceOptions: mocks.getOptions,
|
|
updateMessageSubscription: mocks.update,
|
|
}));
|
|
|
|
/** 创建覆盖依赖字段与任意消息源字段的目录元数据。 */
|
|
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
|
return [
|
|
{
|
|
description: 'STUN 端口变化',
|
|
displayName: 'STUN 映射端口变更',
|
|
sourceKey: STUN_SOURCE_KEY,
|
|
subscriptionFields: [
|
|
{
|
|
key: 'portForwardId',
|
|
label: 'STUN 端口转发',
|
|
optionCollection: 'portForwards',
|
|
required: true,
|
|
type: 'select',
|
|
},
|
|
{
|
|
dependsOn: 'portForwardId',
|
|
key: 'ddnsRecordId',
|
|
label: 'IPv4 DDNS 记录',
|
|
optionCollection: 'ddnsRecords',
|
|
required: true,
|
|
type: 'select',
|
|
},
|
|
],
|
|
variables: [],
|
|
version: 1,
|
|
},
|
|
{
|
|
description: '系统健康状态变化',
|
|
displayName: '系统健康状态变化',
|
|
sourceKey: GENERIC_SOURCE_KEY,
|
|
subscriptionFields: [
|
|
{
|
|
key: 'serviceId',
|
|
label: '服务',
|
|
optionCollection: 'services',
|
|
required: true,
|
|
type: 'select',
|
|
},
|
|
{
|
|
dependsOn: 'serviceId',
|
|
key: 'channelId',
|
|
label: '通道',
|
|
optionCollection: 'channels',
|
|
required: false,
|
|
type: 'select',
|
|
},
|
|
],
|
|
variables: [],
|
|
version: 1,
|
|
},
|
|
];
|
|
}
|
|
|
|
/** 创建 STUN 消息源的标准候选项响应。 */
|
|
function createStunOptions(): QqbotMessagePushApi.SystemMessageSourceOptionsResponse {
|
|
return {
|
|
ddnsRecords: [
|
|
{
|
|
dependsOnValue: '2041700000000000001',
|
|
disabled: false,
|
|
disabledReasonCode: null,
|
|
label: 'Pal DDNS · pal.kwitsukasa.top',
|
|
value: '2041700000000000002',
|
|
},
|
|
{
|
|
dependsOnValue: '2041700000000000001',
|
|
disabled: true,
|
|
disabledReasonCode: 'ddns_disabled',
|
|
label: 'Disabled DDNS · ddns_disabled',
|
|
value: '2041700000000000003',
|
|
},
|
|
{
|
|
dependsOnValue: '2041700000000000005',
|
|
disabled: false,
|
|
disabledReasonCode: null,
|
|
label: 'Other DDNS',
|
|
value: '2041700000000000004',
|
|
},
|
|
],
|
|
portForwards: [
|
|
{
|
|
disabled: false,
|
|
disabledReasonCode: null,
|
|
label: 'Pal UDP · UDP:8213',
|
|
value: '2041700000000000001',
|
|
},
|
|
{
|
|
disabled: true,
|
|
disabledReasonCode: 'keeper_disabled',
|
|
label: 'Disabled UDP · keeper_disabled',
|
|
value: '2041700000000000005',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** 创建非 STUN 消息源的标准候选项响应。 */
|
|
function createGenericOptions(): QqbotMessagePushApi.SystemMessageSourceOptionsResponse {
|
|
return {
|
|
channels: [
|
|
{
|
|
dependsOnValue: 'service-1',
|
|
disabled: false,
|
|
disabledReasonCode: null,
|
|
label: '告警通道',
|
|
value: 'channel-1',
|
|
},
|
|
],
|
|
services: [
|
|
{
|
|
disabled: false,
|
|
disabledReasonCode: null,
|
|
label: 'API 服务',
|
|
value: 'service-1',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** 创建现有通用订阅视图。 */
|
|
function createRow(
|
|
sourceKey = GENERIC_SOURCE_KEY,
|
|
): QqbotMessagePushApi.MessageSubscriptionView {
|
|
return {
|
|
createTime: '2026-07-24 10:00:00',
|
|
enabled: false,
|
|
id: '10000000000000001',
|
|
invalidReasonCode: null,
|
|
name: '旧订阅',
|
|
remark: '旧备注',
|
|
sourceConfig:
|
|
sourceKey === STUN_SOURCE_KEY
|
|
? {
|
|
ddnsRecordId: '2041700000000000002',
|
|
portForwardId: '2041700000000000001',
|
|
}
|
|
: { channelId: 'channel-1', serviceId: 'service-1' },
|
|
sourceKey,
|
|
sourceName: '系统消息源',
|
|
sourceSummary: '现有来源摘要',
|
|
updateTime: '2026-07-24 10:00:00',
|
|
valid: true,
|
|
};
|
|
}
|
|
|
|
/** 创建手动控制完成时机的 Promise。 */
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
/** 挂载消息订阅弹窗。 */
|
|
function mountModal(
|
|
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[] = createSources(),
|
|
) {
|
|
return mount(MessageSubscriptionModal, {
|
|
props: { sources },
|
|
});
|
|
}
|
|
|
|
/** 返回当前表单字段顺序。 */
|
|
function currentFields(): string[] {
|
|
return mocks.formOptions.schema.map((field: any) => field.fieldName);
|
|
}
|
|
|
|
describe('message subscription modal', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.values = {};
|
|
mocks.modalApi.getData.mockImplementation(
|
|
() => mocks.modalApi.setData.mock.calls.at(-1)?.[0] || {},
|
|
);
|
|
mocks.modalApi.setData.mockImplementation(() => mocks.modalApi);
|
|
mocks.modalApi.open.mockImplementation(() => {
|
|
void mocks.modalOptions.onOpenChange?.(true);
|
|
return mocks.modalApi;
|
|
});
|
|
mocks.formApi.getValues.mockImplementation(async () => mocks.values);
|
|
mocks.formApi.setValues.mockImplementation(
|
|
async (patch: Record<string, unknown>) => {
|
|
Object.assign(mocks.values, patch);
|
|
},
|
|
);
|
|
mocks.formApi.validate.mockResolvedValue({ valid: true });
|
|
mocks.create.mockResolvedValue({});
|
|
mocks.update.mockResolvedValue({});
|
|
mocks.getOptions.mockImplementation(async (sourceKey: string) =>
|
|
sourceKey === STUN_SOURCE_KEY
|
|
? createStunOptions()
|
|
: createGenericOptions(),
|
|
);
|
|
});
|
|
|
|
it('新建订阅不默认选择消息源且不预取任何来源候选项', async () => {
|
|
const wrapper = mountModal();
|
|
|
|
(wrapper.vm as any).openCreate();
|
|
await flushPromises();
|
|
|
|
expect(currentFields()).toEqual(['name', 'sourceKey', 'enabled', 'remark']);
|
|
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith(
|
|
{
|
|
enabled: true,
|
|
name: '',
|
|
remark: '',
|
|
sourceKey: '',
|
|
},
|
|
false,
|
|
);
|
|
expect(mocks.getOptions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('选择消息源后按元数据生成字段并过滤依赖候选项', async () => {
|
|
mountModal();
|
|
|
|
await mocks.formOptions.handleValuesChange({ sourceKey: STUN_SOURCE_KEY }, [
|
|
'sourceKey',
|
|
]);
|
|
|
|
expect(mocks.getOptions).toHaveBeenCalledWith(STUN_SOURCE_KEY);
|
|
expect(currentFields()).toEqual([
|
|
'name',
|
|
'sourceKey',
|
|
'portForwardId',
|
|
'ddnsRecordId',
|
|
'enabled',
|
|
'remark',
|
|
]);
|
|
const portField = mocks.formOptions.schema.find(
|
|
(field: any) => field.fieldName === 'portForwardId',
|
|
);
|
|
expect(portField.componentProps().options[1]).toMatchObject({
|
|
disabled: true,
|
|
disabledReasonCode: 'keeper_disabled',
|
|
value: '2041700000000000005',
|
|
});
|
|
|
|
await mocks.formOptions.handleValuesChange(
|
|
{
|
|
ddnsRecordId: '2041700000000000002',
|
|
portForwardId: '2041700000000000001',
|
|
sourceKey: STUN_SOURCE_KEY,
|
|
},
|
|
['portForwardId'],
|
|
);
|
|
const ddnsField = mocks.formOptions.schema.find(
|
|
(field: any) => field.fieldName === 'ddnsRecordId',
|
|
);
|
|
expect(ddnsField.componentProps().options).toEqual([
|
|
expect.objectContaining({ value: '2041700000000000002' }),
|
|
expect.objectContaining({
|
|
disabled: true,
|
|
value: '2041700000000000003',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('切换消息源时清理旧字段并只显示新来源声明字段', async () => {
|
|
mountModal();
|
|
await mocks.formOptions.handleValuesChange({ sourceKey: STUN_SOURCE_KEY }, [
|
|
'sourceKey',
|
|
]);
|
|
await mocks.formOptions.handleValuesChange(
|
|
{
|
|
ddnsRecordId: '2041700000000000002',
|
|
portForwardId: '2041700000000000001',
|
|
},
|
|
['portForwardId'],
|
|
);
|
|
|
|
await mocks.formOptions.handleValuesChange(
|
|
{ sourceKey: GENERIC_SOURCE_KEY },
|
|
['sourceKey'],
|
|
);
|
|
|
|
expect(mocks.formApi.setValues).toHaveBeenCalledWith(
|
|
{
|
|
ddnsRecordId: undefined,
|
|
portForwardId: undefined,
|
|
},
|
|
false,
|
|
);
|
|
expect(currentFields()).toEqual([
|
|
'name',
|
|
'sourceKey',
|
|
'serviceId',
|
|
'channelId',
|
|
'enabled',
|
|
'remark',
|
|
]);
|
|
expect(mocks.getOptions).toHaveBeenLastCalledWith(GENERIC_SOURCE_KEY);
|
|
});
|
|
|
|
it('快速切换消息源时忽略旧来源的迟到响应', async () => {
|
|
const stunRequest =
|
|
createDeferred<QqbotMessagePushApi.SystemMessageSourceOptionsResponse>();
|
|
const genericRequest =
|
|
createDeferred<QqbotMessagePushApi.SystemMessageSourceOptionsResponse>();
|
|
mocks.getOptions.mockImplementation((sourceKey: string) =>
|
|
sourceKey === STUN_SOURCE_KEY
|
|
? stunRequest.promise
|
|
: genericRequest.promise,
|
|
);
|
|
mountModal();
|
|
|
|
const staleSelection = mocks.formOptions.handleValuesChange(
|
|
{ sourceKey: STUN_SOURCE_KEY },
|
|
['sourceKey'],
|
|
);
|
|
await flushPromises();
|
|
const latestSelection = mocks.formOptions.handleValuesChange(
|
|
{ sourceKey: GENERIC_SOURCE_KEY },
|
|
['sourceKey'],
|
|
);
|
|
genericRequest.resolve(createGenericOptions());
|
|
await latestSelection;
|
|
stunRequest.resolve(createStunOptions());
|
|
await staleSelection;
|
|
|
|
expect(currentFields()).toContain('serviceId');
|
|
expect(currentFields()).not.toContain('portForwardId');
|
|
const serviceField = mocks.formOptions.schema.find(
|
|
(field: any) => field.fieldName === 'serviceId',
|
|
);
|
|
expect(serviceField.componentProps().options).toEqual([
|
|
expect.objectContaining({ value: 'service-1' }),
|
|
]);
|
|
});
|
|
|
|
it('编辑时恢复任意消息源配置并按当前 sourceKey 加载候选项', async () => {
|
|
const wrapper = mountModal();
|
|
const row = createRow();
|
|
|
|
(wrapper.vm as any).openEdit(row);
|
|
await flushPromises();
|
|
|
|
expect(mocks.formApi.setValues).toHaveBeenCalledWith(
|
|
{
|
|
channelId: 'channel-1',
|
|
enabled: false,
|
|
name: '旧订阅',
|
|
remark: '旧备注',
|
|
serviceId: 'service-1',
|
|
sourceKey: GENERIC_SOURCE_KEY,
|
|
},
|
|
false,
|
|
);
|
|
expect(mocks.getOptions).toHaveBeenCalledWith(GENERIC_SOURCE_KEY);
|
|
expect(currentFields()).toContain('serviceId');
|
|
expect(currentFields()).toContain('channelId');
|
|
});
|
|
|
|
it('弹窗先打开且消息源目录后返回时补建动态字段', async () => {
|
|
const wrapper = mountModal([]);
|
|
|
|
(wrapper.vm as any).openEdit(createRow(STUN_SOURCE_KEY));
|
|
await flushPromises();
|
|
expect(currentFields()).not.toContain('portForwardId');
|
|
|
|
await wrapper.setProps({ sources: createSources() });
|
|
await flushPromises();
|
|
|
|
expect(currentFields()).toContain('portForwardId');
|
|
expect(currentFields()).toContain('ddnsRecordId');
|
|
});
|
|
|
|
it('提交时只投影所选消息源声明的配置字段', async () => {
|
|
const wrapper = mountModal();
|
|
(wrapper.vm as any).openCreate();
|
|
await flushPromises();
|
|
await mocks.formOptions.handleValuesChange(
|
|
{ sourceKey: GENERIC_SOURCE_KEY },
|
|
['sourceKey'],
|
|
);
|
|
mocks.values = {
|
|
channelId: 'channel-1',
|
|
enabled: true,
|
|
name: ' 通用订阅 ',
|
|
portForwardId: 'stale-port',
|
|
remark: ' managed ',
|
|
serviceId: 'service-1',
|
|
sourceKey: GENERIC_SOURCE_KEY,
|
|
};
|
|
|
|
await mocks.modalOptions.onConfirm();
|
|
|
|
expect(mocks.create).toHaveBeenCalledWith({
|
|
enabled: true,
|
|
name: '通用订阅',
|
|
remark: 'managed',
|
|
sourceConfig: {
|
|
channelId: 'channel-1',
|
|
serviceId: 'service-1',
|
|
},
|
|
sourceKey: GENERIC_SOURCE_KEY,
|
|
});
|
|
expect(mocks.modalApi.close).toHaveBeenCalledOnce();
|
|
expect(wrapper.emitted('saved')).toHaveLength(1);
|
|
});
|
|
|
|
it('普通表单校验失败时不持久化也不锁定弹窗', async () => {
|
|
mocks.formApi.validate.mockResolvedValueOnce({ valid: false });
|
|
const wrapper = mountModal();
|
|
(wrapper.vm as any).openCreate();
|
|
await flushPromises();
|
|
|
|
await mocks.modalOptions.onConfirm();
|
|
|
|
expect(mocks.create).not.toHaveBeenCalled();
|
|
expect(mocks.update).not.toHaveBeenCalled();
|
|
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('持久化失败后保持弹窗可重试并成对解锁', async () => {
|
|
mocks.create.mockRejectedValueOnce(new Error('save failed'));
|
|
const wrapper = mountModal();
|
|
(wrapper.vm as any).openCreate();
|
|
await flushPromises();
|
|
await mocks.formOptions.handleValuesChange(
|
|
{ sourceKey: GENERIC_SOURCE_KEY },
|
|
['sourceKey'],
|
|
);
|
|
mocks.values = {
|
|
enabled: true,
|
|
name: '通用订阅',
|
|
remark: '',
|
|
serviceId: 'service-1',
|
|
sourceKey: GENERIC_SOURCE_KEY,
|
|
};
|
|
|
|
await expect(mocks.modalOptions.onConfirm()).resolves.toBeUndefined();
|
|
expect(mocks.modalApi.close).not.toHaveBeenCalled();
|
|
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
|
|
|
|
mocks.create.mockResolvedValue({});
|
|
await mocks.modalOptions.onConfirm();
|
|
|
|
expect(mocks.modalApi.close).toHaveBeenCalledOnce();
|
|
expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2);
|
|
expect(wrapper.emitted('saved')).toHaveLength(1);
|
|
});
|
|
});
|