kt-template-admin/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx

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);
});
});