kt-template-admin/test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx

720 lines
23 KiB
TypeScript

/* @vitest-environment happy-dom */
/* eslint-disable no-template-curly-in-string, vue/one-component-per-file */
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import MessageTemplateMentions from '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions';
import MessageTemplateModal from '@test-source/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const formValues = {
content: '',
enabled: true,
name: '',
remark: '',
sourceKey: '',
};
const modalApi = {
close: vi.fn(async () => {}),
getData: vi.fn(),
lock: vi.fn(),
open: vi.fn(),
setData: vi.fn(),
unlock: vi.fn(),
};
const formApi = {
getValues: vi.fn(async () => ({ ...formValues })),
resetForm: vi.fn(async () => {}),
resetValidate: vi.fn(async () => {}),
setValues: vi.fn(async (values) => Object.assign(formValues, values)),
validate: vi.fn(async () => ({ valid: true })),
validateField: vi.fn(async (_fieldName: string) => ({ valid: true })),
};
return {
create: vi.fn(),
detail: vi.fn(),
formApi,
formOptions: undefined as any,
formValues,
modalApi,
modalOptions: undefined as any,
preview: vi.fn(),
update: vi.fn(),
};
});
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: 'MockTemplateForm',
setup() {
return () => {
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
const componentProps =
typeof content.componentProps === 'function'
? content.componentProps()
: content.componentProps;
return h('form', { 'data-testid': 'template-form' }, [
h(content.component, {
...componentProps,
value: mocks.formValues.content,
'onUpdate:value': (value: string) => {
mocks.formValues.content = value;
},
}),
]);
};
},
});
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: 'MockTemplateModal',
setup(_, { slots }) {
return () =>
h('section', { 'data-testid': 'template-modal' }, [
slots.default?.(),
h('footer', slots['prepend-footer']?.()),
]);
},
});
return [Modal, mocks.modalApi];
}),
}));
vi.mock('#/api/qqbot/message-push', () => ({
createMessageTemplate: mocks.create,
getMessagePushSourceDetail: mocks.detail,
previewMessageTemplate: mocks.preview,
updateMessageTemplate: mocks.update,
}));
function createSource(
sourceKey = 'source-a',
variableKey = 'endpoint',
): QqbotMessagePushApi.SystemMessageSourceDefinition {
return {
description: `${sourceKey} description`,
displayName: `${sourceKey} name`,
sourceKey,
subscriptionFields: [],
variables: [
{
description: `${variableKey} description`,
example: `${variableKey} example`,
key: variableKey,
label: `${variableKey} label`,
type: 'string',
},
],
version: 1,
};
}
function createRow(): QqbotMessagePushApi.MessageTemplateView {
return {
content: 'old [CQ:at,qq=12345]',
createTime: '2026-07-24 10:00:00',
enabled: false,
id: '10000000000000001',
name: 'old template',
referenceCount: 0,
remark: 'old remark',
sourceKey: 'source-b',
sourceName: 'source-b name',
updateTime: '2026-07-24 10:00:00',
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function mountModal(canPreview = true) {
return mount(MessageTemplateModal, {
props: {
canPreview,
sources: [createSource(), createSource('source-b', 'port')],
},
});
}
describe('message template modal', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(mocks.formValues, {
content: '',
enabled: true,
name: '',
remark: '',
sourceKey: '',
});
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.create.mockResolvedValue({});
mocks.update.mockResolvedValue({});
mocks.formApi.validateField.mockResolvedValue({ valid: true });
mocks.detail.mockImplementation(async (sourceKey: string) =>
createSource(sourceKey, sourceKey === 'source-a' ? 'endpoint' : 'port'),
);
mocks.preview.mockResolvedValue({
renderedMessage: 'server rendered',
variables: { endpoint: 'server.example:38213' },
});
});
it('registers the exact schema and actually renders the local Mentions form field', () => {
const wrapper = mountModal();
const fields = mocks.formOptions.schema.map(
(field: any) => field.fieldName,
);
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
expect(fields).toEqual([
'sourceKey',
'name',
'content',
'enabled',
'remark',
]);
expect(content.component).toBe(MessageTemplateMentions);
expect(content.modelPropName).toBe('value');
expect(
wrapper
.get('[data-testid="template-form"]')
.findComponent(MessageTemplateMentions)
.exists(),
).toBe(true);
expect(
mocks.formOptions.schema.find((field: any) => field.fieldName === 'name')
.componentProps.maxlength,
).toBe(100);
expect(
mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'remark',
).componentProps.maxlength,
).toBe(500);
});
it('opens before reset/set/reset-validation and isolates edit then create', async () => {
const wrapper = mountModal();
mocks.formApi.resetForm.mockImplementation(async () => {
expect(mocks.modalApi.open).toHaveBeenCalled();
});
(wrapper.vm as any).openEdit(createRow());
await flushPromises();
(wrapper.vm as any).openCreate();
await flushPromises();
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
content: '',
enabled: true,
name: '',
remark: '',
sourceKey: 'source-a',
});
const resetOrder =
mocks.formApi.resetForm.mock.invocationCallOrder.at(-1) || 0;
const setOrder =
mocks.formApi.setValues.mock.invocationCallOrder.at(-1) || 0;
const validateOrder =
mocks.formApi.resetValidate.mock.invocationCallOrder.at(-1) || 0;
expect(resetOrder).toBeLessThan(setOrder);
expect(setOrder).toBeLessThan(validateOrder);
});
it('discards an old create confirmation before a new edit session submits its own row', async () => {
const validation = createDeferred<{ valid: boolean }>();
mocks.formApi.validate.mockReturnValueOnce(validation.promise);
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: 'old create',
enabled: true,
name: 'old create',
remark: '',
sourceKey: 'source-a',
});
const staleConfirmation = mocks.modalOptions.onConfirm();
await flushPromises();
const editRow = {
...createRow(),
content: 'new edit ${{port}}',
id: '10000000000000002',
name: ' new edit template ',
remark: ' new edit note ',
};
(wrapper.vm as any).openEdit(editRow);
await flushPromises();
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
validation.resolve({ valid: true });
await staleConfirmation;
await flushPromises();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.formValues).toEqual({
content: 'new edit ${{port}}',
enabled: false,
name: ' new edit template ',
remark: ' new edit note ',
sourceKey: 'source-b',
});
await mocks.modalOptions.onConfirm();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).toHaveBeenCalledOnce();
expect(mocks.update).toHaveBeenCalledWith('10000000000000002', {
content: 'new edit ${{port}}',
enabled: false,
name: 'new edit template',
remark: 'new edit note',
sourceKey: 'source-b',
});
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
closeCountBeforeResume + 1,
);
expect(wrapper.emitted('saved')).toHaveLength(1);
});
it('discards an old edit confirmation before a new create session submits its own payload', async () => {
const values = createDeferred<{
content: string;
enabled: boolean;
name: string;
remark: string;
sourceKey: string;
}>();
mocks.formApi.getValues.mockReturnValueOnce(values.promise);
const wrapper = mountModal();
(wrapper.vm as any).openEdit(createRow());
await flushPromises();
const staleConfirmation = mocks.modalOptions.onConfirm();
await flushPromises();
expect(mocks.formApi.getValues).toHaveBeenCalledOnce();
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: 'new create ${{endpoint}}',
enabled: true,
name: ' new create template ',
remark: ' new create note ',
sourceKey: 'source-a',
});
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
values.resolve({
content: 'old edit [CQ:at,qq=12345]',
enabled: false,
name: 'old edit template',
remark: 'old edit note',
sourceKey: 'source-b',
});
await staleConfirmation;
await flushPromises();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.formValues).toEqual({
content: 'new create ${{endpoint}}',
enabled: true,
name: ' new create template ',
remark: ' new create note ',
sourceKey: 'source-a',
});
await mocks.modalOptions.onConfirm();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.create).toHaveBeenCalledOnce();
expect(mocks.create).toHaveBeenCalledWith({
content: 'new create ${{endpoint}}',
enabled: true,
name: 'new create template',
remark: 'new create note',
sourceKey: 'source-a',
});
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
closeCountBeforeResume + 1,
);
expect(wrapper.emitted('saved')).toHaveLength(1);
});
it('loads exact details and prevents a late source from overwriting the latest', async () => {
const deferred = new Map<
string,
{
promise: Promise<QqbotMessagePushApi.SystemMessageSourceDefinition>;
resolve: (
value: QqbotMessagePushApi.SystemMessageSourceDefinition,
) => void;
}
>();
mocks.detail.mockImplementation((sourceKey: string) => {
let resolve!: (
value: QqbotMessagePushApi.SystemMessageSourceDefinition,
) => void;
const promise =
new Promise<QqbotMessagePushApi.SystemMessageSourceDefinition>(
(done) => {
resolve = done;
},
);
deferred.set(sourceKey, { promise, resolve });
return promise;
});
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
expect(mocks.detail).toHaveBeenNthCalledWith(1, 'source-a');
const switchPromise = mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'],
);
expect(mocks.detail).toHaveBeenNthCalledWith(2, 'source-b');
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
expect(content.componentProps().variables).toEqual([]);
deferred.get('source-b')?.resolve(createSource('source-b', 'port'));
await switchPromise;
expect(content.componentProps().variables[0].key).toBe('port');
deferred.get('source-a')?.resolve(createSource('source-a', 'endpoint'));
await flushPromises();
expect(content.componentProps().variables[0].key).toBe('port');
expect(mocks.formApi.validateField).toHaveBeenCalledWith('content');
});
it('deduplicates exact-key detail and contains a rejected source-change callback before retry', async () => {
let resolveSourceA!: (
source: QqbotMessagePushApi.SystemMessageSourceDefinition,
) => void;
mocks.detail.mockImplementationOnce(
() =>
new Promise<QqbotMessagePushApi.SystemMessageSourceDefinition>(
(resolve) => {
resolveSourceA = resolve;
},
),
);
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
const collidingLoad = mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-a' },
['sourceKey'],
);
expect(mocks.detail).toHaveBeenCalledTimes(1);
resolveSourceA(createSource('source-a', 'endpoint'));
await collidingLoad;
await flushPromises();
mocks.detail.mockRejectedValueOnce(new Error('detail failed'));
const failedSourceChange = mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'],
);
await expect(failedSourceChange).resolves.toBeUndefined();
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
expect(content.componentProps().variables).toEqual([]);
expect(content.componentProps().loading).toBe(false);
expect(mocks.formApi.validateField).toHaveBeenCalledWith('content');
mocks.detail.mockResolvedValueOnce(createSource('source-b', 'port'));
await mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'],
);
expect(mocks.detail).toHaveBeenCalledTimes(3);
expect(content.componentProps().variables[0].key).toBe('port');
expect(content.componentProps().loading).toBe(false);
});
it('contains a non-awaited modal-open detail failure and remains usable for retry', async () => {
mocks.detail.mockRejectedValueOnce(new Error('open detail failed'));
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
expect(content.componentProps().variables).toEqual([]);
expect(content.componentProps().loading).toBe(false);
expect(wrapper.find('footer button').exists()).toBe(true);
mocks.detail.mockResolvedValueOnce(createSource('source-a', 'endpoint'));
(wrapper.vm as any).openCreate();
await flushPromises();
expect(mocks.detail).toHaveBeenCalledTimes(2);
expect(content.componentProps().variables[0].key).toBe('endpoint');
expect(content.componentProps().loading).toBe(false);
});
it('allows preview only on explicit permitted click and clears it on content change', async () => {
const wrapper = mountModal(true);
Object.assign(mocks.formValues, {
content: '[CQ:at,qq=12345] ${{endpoint}}',
sourceKey: 'source-a',
});
expect(mocks.preview).not.toHaveBeenCalled();
await wrapper.get('footer button').trigger('click');
await flushPromises();
expect(mocks.formApi.validateField.mock.calls).toEqual([
['sourceKey'],
['content'],
]);
expect(mocks.preview).toHaveBeenCalledOnce();
expect(mocks.preview).toHaveBeenCalledWith({
content: '[CQ:at,qq=12345] ${{endpoint}}',
sourceKey: 'source-a',
});
expect(wrapper.text()).toContain('server rendered');
expect(wrapper.text()).toContain('server.example:38213');
await mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, content: 'changed' },
['content'],
);
await nextTick();
expect(wrapper.text()).not.toContain('server rendered');
expect(mocks.detail).not.toHaveBeenCalled();
expect(mocks.preview).toHaveBeenCalledOnce();
});
it('makes preview impossible without Preview permission or exact-field validity', async () => {
const wrapper = mountModal(false);
expect(wrapper.find('footer button').exists()).toBe(false);
expect(mocks.formApi.validateField).not.toHaveBeenCalled();
expect(mocks.preview).not.toHaveBeenCalled();
wrapper.unmount();
const permitted = mountModal(true);
Object.assign(mocks.formValues, {
content: 'x'.repeat(2001),
sourceKey: 'source-a',
});
mocks.formApi.validateField.mockImplementation(
async (fieldName: string) => ({
valid: fieldName !== 'content',
}),
);
await permitted.get('footer button').trigger('click');
await flushPromises();
expect(mocks.formApi.validateField.mock.calls).toEqual([
['sourceKey'],
['content'],
]);
expect(mocks.preview).not.toHaveBeenCalled();
mocks.formApi.validateField.mockClear();
mocks.formApi.validateField.mockImplementation(
async (fieldName: string) => ({
valid: fieldName !== 'sourceKey',
}),
);
Object.assign(mocks.formValues, {
content: '${{endpoint}}',
sourceKey: 'schema-invalid-source',
});
await permitted.get('footer button').trigger('click');
await flushPromises();
expect(mocks.formApi.validateField.mock.calls).toEqual([
['sourceKey'],
['content'],
]);
expect(mocks.preview).not.toHaveBeenCalled();
});
it('does not validate unrelated invalid name or remark before preview', async () => {
const wrapper = mountModal(true);
Object.assign(mocks.formValues, {
content: '${{endpoint}}',
name: '',
remark: 'x'.repeat(501),
sourceKey: 'source-a',
});
mocks.formApi.validateField.mockImplementation(
async (fieldName: string) => ({
valid: fieldName !== 'name' && fieldName !== 'remark',
}),
);
await wrapper.get('footer button').trigger('click');
await flushPromises();
expect(mocks.formApi.validateField.mock.calls).toEqual([
['sourceKey'],
['content'],
]);
expect(mocks.preview).toHaveBeenCalledOnce();
});
it('submits exact create/update payloads while preserving content and string ID', async () => {
const wrapper = mountModal();
Object.assign(mocks.formValues, {
content: ' [CQ:at,qq=12345]\n${{endpoint}} ',
enabled: 1,
name: ' template ',
remark: ' note ',
sourceKey: 'source-a',
});
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: ' [CQ:at,qq=12345]\n${{endpoint}} ',
enabled: 1,
name: ' template ',
remark: ' note ',
sourceKey: 'source-a',
});
await mocks.modalOptions.onConfirm();
const payload = {
content: ' [CQ:at,qq=12345]\n${{endpoint}} ',
enabled: true,
name: 'template',
remark: 'note',
sourceKey: 'source-a',
};
expect(mocks.create).toHaveBeenCalledWith(payload);
(wrapper.vm as any).openEdit(createRow());
await flushPromises();
Object.assign(mocks.formValues, payload);
await mocks.modalOptions.onConfirm();
expect(mocks.update).toHaveBeenCalledWith('10000000000000001', payload);
});
it('does not persist or lock when normal form validation fails', async () => {
mocks.formApi.validate.mockResolvedValueOnce({ valid: false });
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
await expect(mocks.modalOptions.onConfirm()).resolves.toBeUndefined();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
});
it('contains failure and closes/emits once on retry success', async () => {
mocks.create.mockRejectedValueOnce(new Error('save failed'));
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: '${{endpoint}}',
enabled: true,
name: ' template ',
remark: ' note ',
sourceKey: 'source-a',
});
const expectedPayload = {
content: '${{endpoint}}',
enabled: true,
name: 'template',
remark: 'note',
sourceKey: 'source-a',
};
await expect(mocks.modalOptions.onConfirm()).resolves.toBeUndefined();
expect(mocks.modalApi.lock).toHaveBeenCalledOnce();
expect(mocks.modalApi.close).not.toHaveBeenCalled();
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
expect(mocks.create).toHaveBeenNthCalledWith(1, expectedPayload);
mocks.create.mockResolvedValueOnce({});
await mocks.modalOptions.onConfirm();
expect(mocks.modalApi.close).toHaveBeenCalledOnce();
expect(wrapper.emitted('saved')).toHaveLength(1);
expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2);
expect(mocks.create).toHaveBeenNthCalledWith(2, expectedPayload);
});
it('contains persistence failure when the real modal owner discards the callback promise', async () => {
mocks.create.mockRejectedValueOnce(new Error('discarded save failed'));
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: '${{endpoint}}',
enabled: true,
name: 'template',
remark: '',
sourceKey: 'source-a',
});
void mocks.modalOptions.onConfirm();
await vi.waitFor(() => {
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
});
expect(mocks.modalApi.close).not.toHaveBeenCalled();
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.create).toHaveBeenCalledOnce();
});
});