389 lines
12 KiB
TypeScript
389 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.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);
|
|
});
|
|
});
|