fix: 收敛消息推送弹窗保存异常

This commit is contained in:
sunlei 2026-07-24 18:56:23 +08:00
parent c67fc1b3de
commit b42a393a41
6 changed files with 182 additions and 14 deletions

View File

@ -532,6 +532,20 @@ describe('account message-push modal', () => {
expect(mocks.update).not.toHaveBeenCalled();
});
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('closes and invalidates an open session when the implicit selfId changes', async () => {
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
@ -616,7 +630,7 @@ describe('account message-push modal', () => {
expect(wrapper.emitted('saved')).toBeUndefined();
});
it('keeps a rejected session open and closes/emits exactly once on success', async () => {
it('contains a rejected session and closes/emits exactly once on retry success', async () => {
mocks.create.mockRejectedValueOnce(new Error('save failed'));
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
@ -627,17 +641,55 @@ describe('account message-push modal', () => {
targets: [{ targetId: '12345', targetType: 'group' }],
templateId: '50000000000000001',
};
const expectedPayload = {
enabled: true,
subscriptionId: '20000000000000001',
targets: [{ targetId: '12345', targetType: 'group' as const }],
templateId: '50000000000000001',
};
await expect(mocks.modalOptions.onConfirm()).rejects.toThrow('save failed');
await expect(mocks.modalOptions.onConfirm()).resolves.toBeUndefined();
expect(mocks.modalApi.lock).toHaveBeenCalledOnce();
expect(mocks.modalApi.close).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.create).toHaveBeenNthCalledWith(
1,
'10000000000000001',
expectedPayload,
);
mocks.create.mockResolvedValue({});
await mocks.modalOptions.onConfirm();
expect(mocks.modalApi.close).toHaveBeenCalledOnce();
expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2);
expect(wrapper.emitted('saved')).toHaveLength(1);
expect(mocks.create).toHaveBeenNthCalledWith(
2,
'10000000000000001',
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();
mocks.currentValues = {
enabled: true,
subscriptionId: '20000000000000001',
targets: [{ targetId: '12345', targetType: 'group' }],
templateId: '50000000000000001',
};
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();
});
});

View File

@ -107,9 +107,16 @@ export default defineComponent({
const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]',
fullscreenButton: false,
/** Validates and persists the active account-scoped session. */
/**
* Persists the active session while containing failures at ModalApi's
* non-awaited callback boundary.
*/
async onConfirm() {
await submit();
try {
await submit();
} catch {
// The request/form layer already presents the persistence error.
}
},
/**
* Restores only the latest session after destroy-on-close content mounts.

View File

@ -422,6 +422,20 @@ describe('message subscription modal', () => {
);
});
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('locks, closes, emits once, and unlocks only after successful persistence', async () => {
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
@ -443,18 +457,29 @@ describe('message subscription modal', () => {
expect(lockOrder).toBeLessThan(createOrder);
});
it('keeps the modal usable and emits nothing after a rejected mutation', async () => {
mocks.create.mockRejectedValue(new Error('save failed'));
it('contains a rejected mutation and remains usable for retry', async () => {
mocks.create.mockRejectedValueOnce(new Error('save failed'));
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
const expectedPayload = {
enabled: true,
name: '帕鲁端口变更',
remark: 'managed',
sourceConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
sourceKey: 'network.stun.mapping-port-changed',
};
await expect(mocks.modalOptions.onConfirm()).rejects.toThrow('save failed');
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.mockResolvedValue({});
await mocks.modalOptions.onConfirm();
@ -462,5 +487,22 @@ describe('message subscription modal', () => {
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();
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();
});
});

View File

@ -95,9 +95,16 @@ export default defineComponent({
const [Modal, modalApi] = useVbenModal({
class: 'w-[680px]',
fullscreenButton: false,
/** Validates and persists the current modal session. */
/**
* Persists the current session while containing failures at ModalApi's
* non-awaited callback boundary.
*/
async onConfirm() {
await submit();
try {
await submit();
} catch {
// The request/form layer already presents the persistence error.
}
},
/**
* Restores session values after destroy-on-close modal content has mounted.

View File

@ -513,22 +513,75 @@ describe('message template modal', () => {
expect(mocks.update).toHaveBeenCalledWith('10000000000000001', payload);
});
it('stays open/unlocked after failure and closes/emits once on retry success', async () => {
mocks.create.mockRejectedValueOnce(new Error('save failed'));
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()).rejects.toThrow('save failed');
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();
});
});

View File

@ -103,9 +103,16 @@ export default defineComponent({
const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]',
fullscreenButton: false,
/** Validates and persists the exact five-field template payload. */
/**
* Persists the exact payload while containing failures at ModalApi's
* non-awaited callback boundary.
*/
async onConfirm() {
await submit();
try {
await submit();
} catch {
// The request/form layer already presents the persistence error.
}
},
/**
* Resets every mounted form/session state before loading authoritative detail.