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

390 lines
12 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 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<string>(),
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);
});
});