fix: 收紧消息模板预览与加载错误

This commit is contained in:
sunlei 2026-07-24 17:14:13 +08:00
parent e1d9ecfd66
commit 4c41a2bdff
2 changed files with 106 additions and 10 deletions

View File

@ -34,7 +34,7 @@ const mocks = vi.hoisted(() => {
resetValidate: vi.fn(async () => {}), resetValidate: vi.fn(async () => {}),
setValues: vi.fn(async (values) => Object.assign(formValues, values)), setValues: vi.fn(async (values) => Object.assign(formValues, values)),
validate: vi.fn(async () => ({ valid: true })), validate: vi.fn(async () => ({ valid: true })),
validateField: vi.fn(async () => ({ valid: true })), validateField: vi.fn(async (_fieldName: string) => ({ valid: true })),
}; };
return { return {
create: vi.fn(), create: vi.fn(),
@ -187,6 +187,7 @@ describe('message template modal', () => {
}); });
mocks.create.mockResolvedValue({}); mocks.create.mockResolvedValue({});
mocks.update.mockResolvedValue({}); mocks.update.mockResolvedValue({});
mocks.formApi.validateField.mockResolvedValue({ valid: true });
mocks.detail.mockImplementation(async (sourceKey: string) => mocks.detail.mockImplementation(async (sourceKey: string) =>
createSource(sourceKey, sourceKey === 'source-a' ? 'endpoint' : 'port'), createSource(sourceKey, sourceKey === 'source-a' ? 'endpoint' : 'port'),
); );
@ -305,7 +306,7 @@ describe('message template modal', () => {
expect(mocks.formApi.validateField).toHaveBeenCalledWith('content'); expect(mocks.formApi.validateField).toHaveBeenCalledWith('content');
}); });
it('deduplicates exact-key detail and evicts a rejected cache entry for retry', async () => { it('deduplicates exact-key detail and contains a rejected source-change callback before retry', async () => {
let resolveSourceA!: ( let resolveSourceA!: (
source: QqbotMessagePushApi.SystemMessageSourceDefinition, source: QqbotMessagePushApi.SystemMessageSourceDefinition,
) => void; ) => void;
@ -330,21 +331,48 @@ describe('message template modal', () => {
await flushPromises(); await flushPromises();
mocks.detail.mockRejectedValueOnce(new Error('detail failed')); mocks.detail.mockRejectedValueOnce(new Error('detail failed'));
await expect( const failedSourceChange = mocks.formOptions.handleValuesChange(
mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' }, { ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'], ['sourceKey'],
), );
).rejects.toThrow('detail failed'); 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')); mocks.detail.mockResolvedValueOnce(createSource('source-b', 'port'));
await mocks.formOptions.handleValuesChange( await mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' }, { ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'], ['sourceKey'],
); );
expect(mocks.detail).toHaveBeenCalledTimes(3); 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( const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content', (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); expect(content.componentProps().loading).toBe(false);
}); });
@ -359,6 +387,10 @@ describe('message template modal', () => {
await wrapper.get('footer button').trigger('click'); await wrapper.get('footer button').trigger('click');
await flushPromises(); await flushPromises();
expect(mocks.formApi.validateField.mock.calls).toEqual([
['sourceKey'],
['content'],
]);
expect(mocks.preview).toHaveBeenCalledOnce(); expect(mocks.preview).toHaveBeenCalledOnce();
expect(mocks.preview).toHaveBeenCalledWith({ expect(mocks.preview).toHaveBeenCalledWith({
content: '[CQ:at,qq=12345] ${{endpoint}}', content: '[CQ:at,qq=12345] ${{endpoint}}',
@ -377,16 +409,72 @@ describe('message template modal', () => {
expect(mocks.preview).toHaveBeenCalledOnce(); expect(mocks.preview).toHaveBeenCalledOnce();
}); });
it('makes preview impossible without Preview permission or valid values', async () => { it('makes preview impossible without Preview permission or exact-field validity', async () => {
const wrapper = mountModal(false); const wrapper = mountModal(false);
expect(wrapper.find('footer button').exists()).toBe(false); expect(wrapper.find('footer button').exists()).toBe(false);
expect(mocks.formApi.validateField).not.toHaveBeenCalled();
expect(mocks.preview).not.toHaveBeenCalled(); expect(mocks.preview).not.toHaveBeenCalled();
wrapper.unmount(); wrapper.unmount();
const permitted = mountModal(true); const permitted = mountModal(true);
Object.assign(mocks.formValues, { content: '', sourceKey: 'source-a' }); 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 permitted.get('footer button').trigger('click');
await flushPromises();
expect(mocks.formApi.validateField.mock.calls).toEqual([
['sourceKey'],
['content'],
]);
expect(mocks.preview).not.toHaveBeenCalled(); 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 () => { it('submits exact create/update payloads while preserving content and string ID', async () => {

View File

@ -201,6 +201,7 @@ export default defineComponent({
/** /**
* Replaces suggestions only when this exact source request remains latest. * Replaces suggestions only when this exact source request remains latest.
* Expected detail failures stay inside the non-awaited form/modal callbacks.
* @param sourceKey - Newly selected source identity. * @param sourceKey - Newly selected source identity.
*/ */
async function loadSourceDetail(sourceKey: string) { async function loadSourceDetail(sourceKey: string) {
@ -220,6 +221,8 @@ export default defineComponent({
) { ) {
variables.value = source.variables; variables.value = source.variables;
} }
} catch {
// The request layer owns user-facing errors; an empty catalog remains usable.
} finally { } finally {
if ( if (
revision === sourceRevision && revision === sourceRevision &&
@ -234,6 +237,11 @@ export default defineComponent({
/** Requests server-authoritative preview only after an explicit valid click. */ /** Requests server-authoritative preview only after an explicit valid click. */
async function handlePreview() { async function handlePreview() {
if (!props.canPreview) return; if (!props.canPreview) return;
const [sourceValidation, contentValidation] = await Promise.all([
formApi.validateField('sourceKey'),
formApi.validateField('content'),
]);
if (!sourceValidation.valid || !contentValidation.valid) return;
const values = await formApi.getValues<MessageTemplateFormValues>(); const values = await formApi.getValues<MessageTemplateFormValues>();
if (!values.sourceKey || !values.content) return; if (!values.sourceKey || !values.content) return;
const revision = ++previewRevision; const revision = ++previewRevision;