399 lines
12 KiB
TypeScript
399 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 MessageTemplateList from '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/list';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const registerTable = vi.fn();
|
|
const tableApi = {
|
|
reload: vi.fn(async () => {
|
|
if (!mocks.tableOptions) {
|
|
throw new Error('[MockKtTable]: table is not registered yet');
|
|
}
|
|
return mocks.tableOptions.api.list({ pageNo: 1, pageSize: 10 });
|
|
}),
|
|
};
|
|
return {
|
|
accessCodes: new Set<string>(),
|
|
api: {
|
|
delete: vi.fn(),
|
|
detail: vi.fn(),
|
|
getList: vi.fn(),
|
|
getSources: vi.fn(),
|
|
preview: vi.fn(),
|
|
toggle: vi.fn(),
|
|
},
|
|
modalOpenCreate: vi.fn(),
|
|
modalOpenEdit: vi.fn(),
|
|
registerTable,
|
|
tableApi,
|
|
tableOptions: undefined as any,
|
|
};
|
|
});
|
|
|
|
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('@vben/icons', () => ({
|
|
Plus: defineComponent({ render: () => h('i') }),
|
|
}));
|
|
|
|
vi.mock('antdv-next/dist/tag/index', () => ({
|
|
default: defineComponent({
|
|
name: 'MockTag',
|
|
setup(_, { slots }) {
|
|
return () => h('span', slots.default?.());
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock('#/api/qqbot/message-push', () => ({
|
|
deleteMessageTemplate: mocks.api.delete,
|
|
getMessagePushSourceDetail: mocks.api.detail,
|
|
getMessagePushSources: mocks.api.getSources,
|
|
getMessageTemplateList: mocks.api.getList,
|
|
previewMessageTemplate: mocks.api.preview,
|
|
setMessageTemplateEnabled: mocks.api.toggle,
|
|
}));
|
|
|
|
vi.mock('#/components/ktTable', () => ({
|
|
KtTable: defineComponent({
|
|
name: 'MockKtTable',
|
|
emits: ['register'],
|
|
setup(_, { emit, slots }) {
|
|
emit('register', { registered: true });
|
|
return () =>
|
|
h('section', { 'data-testid': 'template-table' }, [
|
|
h(
|
|
'button',
|
|
{
|
|
'data-testid': 'explicit-refresh',
|
|
onClick: () => mocks.tableApi.reload(),
|
|
},
|
|
'refresh',
|
|
),
|
|
slots.bodyCell?.({
|
|
column: { key: 'contentSummary' },
|
|
record: createRow({ content: '[CQ:at,qq=12345] <plain>' }),
|
|
}),
|
|
]);
|
|
},
|
|
}),
|
|
useKtTable: vi.fn((options) => {
|
|
mocks.tableOptions = undefined;
|
|
mocks.registerTable.mockImplementation(() => {
|
|
mocks.tableOptions = options;
|
|
});
|
|
return [mocks.registerTable, mocks.tableApi];
|
|
}),
|
|
}));
|
|
|
|
vi.mock(
|
|
'@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal',
|
|
() => ({
|
|
default: defineComponent({
|
|
name: 'MockMessageTemplateModal',
|
|
props: {
|
|
canPreview: Boolean,
|
|
sources: {
|
|
default: () => [],
|
|
type: Array,
|
|
},
|
|
},
|
|
emits: ['saved'],
|
|
setup(props, { emit, expose }) {
|
|
expose({
|
|
openCreate: mocks.modalOpenCreate,
|
|
openEdit: mocks.modalOpenEdit,
|
|
});
|
|
return () =>
|
|
h(
|
|
'button',
|
|
{
|
|
'data-can-preview': String(props.canPreview),
|
|
'data-testid': 'modal-saved',
|
|
onClick: () => emit('saved'),
|
|
},
|
|
'saved',
|
|
);
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
|
|
function createRow(
|
|
overrides: Partial<QqbotMessagePushApi.MessageTemplateView> = {},
|
|
): QqbotMessagePushApi.MessageTemplateView {
|
|
return {
|
|
content: 'content',
|
|
createTime: '2026-07-24 10:00:00',
|
|
enabled: true,
|
|
id: '10000000000000001',
|
|
name: 'template',
|
|
referenceCount: 0,
|
|
remark: null,
|
|
sourceKey: 'network.stun.mapping-port-changed',
|
|
sourceName: 'STUN 映射端口变更',
|
|
updateTime: '2026-07-24 10:00:00',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
|
return [
|
|
{
|
|
description: 'STUN mapping changed',
|
|
displayName: 'STUN 映射端口变更',
|
|
sourceKey: 'network.stun.mapping-port-changed',
|
|
subscriptionFields: [],
|
|
variables: [],
|
|
version: 1,
|
|
},
|
|
];
|
|
}
|
|
|
|
describe('message template list', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
mocks.tableOptions = undefined;
|
|
mocks.accessCodes = new Set([
|
|
'QqBot:MessageTemplate:Create',
|
|
'QqBot:MessageTemplate:Delete',
|
|
'QqBot:MessageTemplate:List',
|
|
'QqBot:MessageTemplate:Preview',
|
|
'QqBot:MessageTemplate:Toggle',
|
|
'QqBot:MessageTemplate:Update',
|
|
]);
|
|
mocks.api.getList.mockResolvedValue({
|
|
items: [createRow()],
|
|
total: 1,
|
|
});
|
|
mocks.api.getSources.mockResolvedValue(createSources());
|
|
mocks.api.toggle.mockResolvedValue({});
|
|
mocks.api.delete.mockResolvedValue(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('renders one stable Page root and requires native KtTable registration', async () => {
|
|
const wrapper = mount(MessageTemplateList);
|
|
await flushPromises();
|
|
|
|
expect(wrapper.get('[data-testid="page-root"]').attributes()).toMatchObject(
|
|
{
|
|
'data-auto-content-height': 'true',
|
|
},
|
|
);
|
|
expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1);
|
|
expect(wrapper.find('[data-testid="template-table"]').exists()).toBe(true);
|
|
expect(mocks.tableOptions.immediate).toBe(false);
|
|
expect(mocks.tableOptions.rowKey).toBe('id');
|
|
expect(mocks.registerTable).toHaveBeenCalledOnce();
|
|
expect(mocks.api.getList).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('pins exact filters, columns, and the unchanged strict page result', async () => {
|
|
mount(MessageTemplateList);
|
|
await flushPromises();
|
|
const result = { items: [createRow()], total: 1 };
|
|
mocks.api.getList.mockResolvedValueOnce(result);
|
|
|
|
await expect(
|
|
mocks.tableOptions.api.list({
|
|
enabled: true,
|
|
name: 'template',
|
|
pageNo: 1,
|
|
pageSize: 10,
|
|
sourceKey: 'network.stun.mapping-port-changed',
|
|
}),
|
|
).resolves.toBe(result);
|
|
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',
|
|
'contentSummary',
|
|
'referenceCount',
|
|
'enabled',
|
|
'updateTime',
|
|
],
|
|
);
|
|
});
|
|
|
|
it('makes zero requests and registration side paths without List permission', async () => {
|
|
mocks.accessCodes = new Set([
|
|
'QqBot:MessageTemplate:Create',
|
|
'QqBot:MessageTemplate:Preview',
|
|
]);
|
|
const wrapper = mount(MessageTemplateList);
|
|
await flushPromises();
|
|
|
|
expect(mocks.registerTable).not.toHaveBeenCalled();
|
|
expect(mocks.tableApi.reload).not.toHaveBeenCalled();
|
|
expect(mocks.api.getList).not.toHaveBeenCalled();
|
|
expect(mocks.api.getSources).not.toHaveBeenCalled();
|
|
expect(mocks.api.detail).not.toHaveBeenCalled();
|
|
expect(mocks.api.preview).not.toHaveBeenCalled();
|
|
expect(wrapper.find('[data-testid="template-table"]').exists()).toBe(false);
|
|
expect(wrapper.find('[data-testid="modal-saved"]').exists()).toBe(false);
|
|
});
|
|
|
|
it('loads list/source once and stays request/timer-free for 60 seconds', async () => {
|
|
mount(MessageTemplateList);
|
|
await flushPromises();
|
|
|
|
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
|
expect(mocks.api.getList).toHaveBeenCalledOnce();
|
|
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
|
expect(mocks.api.detail).not.toHaveBeenCalled();
|
|
expect(mocks.api.preview).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.detail).not.toHaveBeenCalled();
|
|
expect(mocks.api.preview).not.toHaveBeenCalled();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('assigns all exact permissions and passes Preview independently to modal', async () => {
|
|
const wrapper = mount(MessageTemplateList);
|
|
await flushPromises();
|
|
|
|
expect(mocks.tableOptions.buttons[0].permissionCodes).toEqual([
|
|
'QqBot:MessageTemplate:Create',
|
|
]);
|
|
expect(
|
|
Object.fromEntries(
|
|
mocks.tableOptions.rowActions.map((action: any) => [
|
|
action.key,
|
|
action.permissionCodes,
|
|
]),
|
|
),
|
|
).toEqual({
|
|
delete: ['QqBot:MessageTemplate:Delete'],
|
|
edit: ['QqBot:MessageTemplate:Update'],
|
|
toggle: ['QqBot:MessageTemplate:Toggle'],
|
|
});
|
|
expect(
|
|
wrapper.get('[data-testid="modal-saved"]').attributes('data-can-preview'),
|
|
).toBe('true');
|
|
});
|
|
|
|
it('disables referenced delete with a count reason and keeps confirmation otherwise', () => {
|
|
mount(MessageTemplateList);
|
|
const remove = mocks.tableOptions.rowActions.find(
|
|
(action: any) => action.key === 'delete',
|
|
);
|
|
const referenced = createRow({ referenceCount: 3 });
|
|
const unreferenced = createRow({ referenceCount: 0 });
|
|
|
|
expect(remove.disabled(referenced)).toBe(true);
|
|
expect(remove.disabledReason(referenced)).toContain('3');
|
|
expect(remove.disabled(unreferenced)).toBe(false);
|
|
expect(remove.disabledReason(unreferenced)).toBeUndefined();
|
|
expect(remove.confirm(unreferenced)).toContain('template');
|
|
});
|
|
|
|
it('reloads successful row mutations once and rejected mutations zero times', async () => {
|
|
mount(MessageTemplateList);
|
|
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);
|
|
|
|
context.reload.mockClear();
|
|
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 modal, reloads once after saved, and refreshes list metadata-free', async () => {
|
|
const wrapper = mount(MessageTemplateList);
|
|
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');
|
|
await flushPromises();
|
|
expect(mocks.modalOpenCreate).toHaveBeenCalledOnce();
|
|
expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row);
|
|
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
|
expect(mocks.api.getList).toHaveBeenCalledOnce();
|
|
expect(mocks.api.getSources).toHaveBeenCalledOnce();
|
|
expect(mocks.api.detail).not.toHaveBeenCalled();
|
|
expect(mocks.api.preview).not.toHaveBeenCalled();
|
|
|
|
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();
|
|
});
|
|
|
|
it('renders CQ-looking summaries as escaped plain text', async () => {
|
|
const wrapper = mount(MessageTemplateList);
|
|
await flushPromises();
|
|
|
|
expect(wrapper.text()).toContain('[CQ:at,qq=12345] <plain>');
|
|
expect(wrapper.html()).toContain('<plain>');
|
|
expect(wrapper.html()).not.toContain('innerHTML');
|
|
});
|
|
});
|