/* @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() { let resolve!: (value: T) => void; const promise = new Promise((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) => { 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(); const genericRequest = createDeferred(); 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); }); });