/* @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 MessageSubscriptionList from '@test-source/apps/web-antdv-next/src/views/qqbot/message-subscription/list'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const state = { accessCodes: new Set(), api: { delete: vi.fn(), getList: vi.fn(), getSourceOptions: vi.fn(), getSources: vi.fn(), toggle: vi.fn(), }, modalOpenCreate: vi.fn(), modalOpenEdit: vi.fn(), registeredTableOptions: undefined as any, registerTable: vi.fn(), tableApi: { reload: vi.fn(), }, tableOptions: undefined as any, }; state.registerTable.mockImplementation(() => { state.registeredTableOptions = state.tableOptions; }); state.tableApi.reload.mockImplementation(async () => { if (!state.registeredTableOptions) { throw new Error('[MockKtTable]: table is not registered yet.'); } return await state.registeredTableOptions.api.list({ pageNo: 1, pageSize: 10, }); }); return state; }); vi.mock('@vben/access', () => ({ useAccess: () => ({ hasAccessByCodes: (codes: string[]) => codes.every((code) => mocks.accessCodes.has(code)), }), })); vi.mock('@vben/common-ui', () => ({ Page: defineComponent({ name: 'MockPage', props: { autoContentHeight: Boolean }, setup(props, { slots }) { return () => h( 'main', { 'data-auto-content-height': String(props.autoContentHeight), 'data-testid': 'page-root', }, slots.default?.(), ); }, }), })); vi.mock('antdv-next', () => ({ Tag: defineComponent({ name: 'MockTag', setup(_, { slots }) { return () => h('span', slots.default?.()); }, }), })); vi.mock('#/components/ktTable', () => ({ KtTable: defineComponent({ name: 'MockKtTable', emits: ['register'], setup(_, { emit, slots }) { emit('register', {}); return () => h('section', { 'data-testid': 'subscription-table' }, [ h( 'button', { 'data-testid': 'explicit-refresh', onClick: () => mocks.tableApi.reload(), }, 'refresh', ), slots.bodyCell?.({ column: { key: 'enabled' }, record: { enabled: true }, }), ]); }, }), useKtTable: vi.fn((options) => { mocks.tableOptions = options; return [mocks.registerTable, mocks.tableApi]; }), })); vi.mock( '@test-source/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal', () => ({ default: defineComponent({ name: 'MockMessageSubscriptionModal', emits: ['saved'], setup(_, { emit, expose }) { expose({ openCreate: mocks.modalOpenCreate, openEdit: mocks.modalOpenEdit, }); return () => h( 'button', { 'data-testid': 'modal-saved', onClick: () => emit('saved'), }, 'saved', ); }, }), }), ); vi.mock('#/api/qqbot/message-push', () => ({ deleteMessageSubscription: mocks.api.delete, getMessagePushSourceOptions: mocks.api.getSourceOptions, getMessagePushSources: mocks.api.getSources, getMessageSubscriptionList: mocks.api.getList, setMessageSubscriptionEnabled: mocks.api.toggle, })); /** 创建列表测试使用的订阅视图。 */ function createRow(): QqbotMessagePushApi.MessageSubscriptionView { return { createTime: '2026-07-24 10:00:00', enabled: true, id: '10000000000000001', invalidReasonCode: null, name: '帕鲁端口变更', remark: null, sourceConfig: { ddnsRecordId: '2041700000000000002', portForwardId: '2041700000000000001', }, sourceKey: 'network.stun.mapping-port-changed', sourceName: 'STUN 映射端口变更', sourceSummary: 'Pal UDP · pal.kwitsukasa.top', updateTime: '2026-07-24 10:00:00', valid: true, }; } describe('message subscription list', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); mocks.accessCodes = new Set([ 'QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:Delete', 'QqBot:MessageSubscription:List', 'QqBot:MessageSubscription:Toggle', 'QqBot:MessageSubscription:Update', ]); mocks.registeredTableOptions = undefined; mocks.tableOptions = undefined; mocks.api.getSources.mockResolvedValue([ { description: 'STUN 端口变化', displayName: 'STUN 映射端口变更', sourceKey: 'network.stun.mapping-port-changed', subscriptionFields: [], variables: [], version: 1, }, ]); mocks.api.getList.mockResolvedValue({ items: [createRow()], total: 1, }); mocks.api.toggle.mockResolvedValue(createRow()); mocks.api.delete.mockResolvedValue(true); }); afterEach(() => { vi.useRealTimers(); }); it('renders one auto-height Page root and one native KtTable', async () => { const wrapper = mount(MessageSubscriptionList); await flushPromises(); expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1); expect( wrapper .get('[data-testid="page-root"]') .attributes('data-auto-content-height'), ).toBe('true'); expect(wrapper.findAll('[data-testid="subscription-table"]')).toHaveLength( 1, ); expect(mocks.tableOptions.immediate).toBe(false); expect(mocks.tableOptions.rowKey).toBe('id'); expect(mocks.tableOptions).not.toHaveProperty('rowActionVisibleCount'); expect(mocks.registerTable).toHaveBeenCalledOnce(); }); it('pins the exact filters, columns, and strict page-result proxy', async () => { mount(MessageSubscriptionList); await flushPromises(); const pageResult = { items: [createRow()], total: 1 }; mocks.api.getList.mockResolvedValueOnce(pageResult); await expect( mocks.tableOptions.api.list({ enabled: true, name: 'STUN', pageNo: 1, pageSize: 10, sourceKey: 'network.stun.mapping-port-changed', }), ).resolves.toBe(pageResult); expect( mocks.tableOptions.formOptions.schema.map( (field: any) => field.fieldName, ), ).toEqual(['name', 'sourceKey', 'enabled']); expect(mocks.tableOptions.columns.map((column: any) => column.key)).toEqual( ['name', 'source', 'sourceSummary', 'enabled', 'remark', 'updateTime'], ); }); it('makes zero requests and renders no table or modal without List permission', async () => { mocks.accessCodes = new Set([ 'QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:Update', ]); const wrapper = mount(MessageSubscriptionList); await flushPromises(); expect(mocks.tableApi.reload).not.toHaveBeenCalled(); expect(mocks.registerTable).not.toHaveBeenCalled(); expect(mocks.api.getList).not.toHaveBeenCalled(); expect(mocks.api.getSources).not.toHaveBeenCalled(); expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="subscription-table"]').exists()).toBe( false, ); expect(wrapper.find('[data-testid="modal-saved"]').exists()).toBe(false); }); it('loads list and source catalog once without prefetching source options', async () => { mount(MessageSubscriptionList); await flushPromises(); expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); expect(mocks.api.getList).toHaveBeenCalledOnce(); expect(mocks.api.getList).toHaveBeenCalledWith({ pageNo: 1, pageSize: 10, }); await expect( mocks.tableApi.reload.mock.results[0]?.value, ).resolves.toStrictEqual({ items: [createRow()], total: 1, }); expect(mocks.api.getSources).toHaveBeenCalledOnce(); expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); vi.advanceTimersByTime(60_000); await flushPromises(); expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); expect(mocks.api.getList).toHaveBeenCalledOnce(); expect(mocks.api.getSources).toHaveBeenCalledOnce(); expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); it('assigns the exact create/edit/toggle/delete permissions and confirmation', () => { mount(MessageSubscriptionList); expect(mocks.tableOptions.buttons[0].permissionCodes).toEqual([ 'QqBot:MessageSubscription:Create', ]); expect( Object.fromEntries( mocks.tableOptions.rowActions.map((action: any) => [ action.key, action.permissionCodes, ]), ), ).toEqual({ delete: ['QqBot:MessageSubscription:Delete'], edit: ['QqBot:MessageSubscription:Update'], toggle: ['QqBot:MessageSubscription:Toggle'], }); expect( mocks.tableOptions.rowActions.find( (action: any) => action.key === 'delete', ).confirm, ).toBeTypeOf('function'); }); it('reloads the row context once after successful string-ID mutations', async () => { mount(MessageSubscriptionList); const row = createRow(); const context = { reload: vi.fn(async () => {}) }; const toggle = mocks.tableOptions.rowActions.find( (action: any) => action.key === 'toggle', ); const remove = mocks.tableOptions.rowActions.find( (action: any) => action.key === 'delete', ); await toggle.onClick(row, context); await remove.onClick(row, context); expect(mocks.api.toggle).toHaveBeenCalledWith('10000000000000001', false); expect(mocks.api.delete).toHaveBeenCalledWith('10000000000000001'); expect(context.reload).toHaveBeenCalledTimes(2); }); it('does not reload the row context after failed mutations', async () => { mount(MessageSubscriptionList); const row = createRow(); const context = { reload: vi.fn(async () => {}) }; const toggle = mocks.tableOptions.rowActions.find( (action: any) => action.key === 'toggle', ); const remove = mocks.tableOptions.rowActions.find( (action: any) => action.key === 'delete', ); mocks.api.toggle.mockRejectedValueOnce(new Error('toggle failed')); mocks.api.delete.mockRejectedValueOnce(new Error('delete failed')); await expect(toggle.onClick(row, context)).rejects.toThrow('toggle failed'); await expect(remove.onClick(row, context)).rejects.toThrow('delete failed'); expect(context.reload).not.toHaveBeenCalled(); }); it('opens the page modal and reloads the list once after saved', async () => { const wrapper = mount(MessageSubscriptionList); await flushPromises(); mocks.tableApi.reload.mockClear(); mocks.api.getList.mockClear(); const row = createRow(); await mocks.tableOptions.buttons[0].onClick({}); await mocks.tableOptions.rowActions .find((action: any) => action.key === 'edit') .onClick(row, { reload: vi.fn() }); await wrapper.get('[data-testid="modal-saved"]').trigger('click'); expect(mocks.modalOpenCreate).toHaveBeenCalledOnce(); expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row); expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); expect(mocks.api.getList).toHaveBeenCalledOnce(); }); it('explicit refresh reloads only the list and leaves metadata cached', async () => { const wrapper = mount(MessageSubscriptionList); await flushPromises(); mocks.tableApi.reload.mockClear(); mocks.api.getList.mockClear(); await wrapper.get('[data-testid="explicit-refresh"]').trigger('click'); await flushPromises(); expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); expect(mocks.api.getList).toHaveBeenCalledOnce(); expect(mocks.api.getSources).toHaveBeenCalledOnce(); expect(mocks.api.getSourceOptions).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); });