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(); 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 () => { it('closes and invalidates an open session when the implicit selfId changes', async () => {
const wrapper = mountModal(); const wrapper = mountModal();
(wrapper.vm as any).openCreate(); (wrapper.vm as any).openCreate();
@ -616,7 +630,7 @@ describe('account message-push modal', () => {
expect(wrapper.emitted('saved')).toBeUndefined(); 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')); mocks.create.mockRejectedValueOnce(new Error('save failed'));
const wrapper = mountModal(); const wrapper = mountModal();
(wrapper.vm as any).openCreate(); (wrapper.vm as any).openCreate();
@ -627,17 +641,55 @@ describe('account message-push modal', () => {
targets: [{ targetId: '12345', targetType: 'group' }], targets: [{ targetId: '12345', targetType: 'group' }],
templateId: '50000000000000001', 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.lock).toHaveBeenCalledOnce();
expect(mocks.modalApi.close).not.toHaveBeenCalled(); expect(mocks.modalApi.close).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
expect(wrapper.emitted('saved')).toBeUndefined(); expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.create).toHaveBeenNthCalledWith(
1,
'10000000000000001',
expectedPayload,
);
mocks.create.mockResolvedValue({}); mocks.create.mockResolvedValue({});
await mocks.modalOptions.onConfirm(); await mocks.modalOptions.onConfirm();
expect(mocks.modalApi.close).toHaveBeenCalledOnce(); expect(mocks.modalApi.close).toHaveBeenCalledOnce();
expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2); expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2);
expect(wrapper.emitted('saved')).toHaveLength(1); 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({ const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]', class: 'w-[760px]',
fullscreenButton: false, 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() { async onConfirm() {
try {
await submit(); await submit();
} catch {
// The request/form layer already presents the persistence error.
}
}, },
/** /**
* Restores only the latest session after destroy-on-close content mounts. * 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 () => { it('locks, closes, emits once, and unlocks only after successful persistence', async () => {
const wrapper = mountModal(); const wrapper = mountModal();
(wrapper.vm as any).openCreate(); (wrapper.vm as any).openCreate();
@ -443,18 +457,29 @@ describe('message subscription modal', () => {
expect(lockOrder).toBeLessThan(createOrder); expect(lockOrder).toBeLessThan(createOrder);
}); });
it('keeps the modal usable and emits nothing after a rejected mutation', async () => { it('contains a rejected mutation and remains usable for retry', async () => {
mocks.create.mockRejectedValue(new Error('save failed')); mocks.create.mockRejectedValueOnce(new Error('save failed'));
const wrapper = mountModal(); const wrapper = mountModal();
(wrapper.vm as any).openCreate(); (wrapper.vm as any).openCreate();
await flushPromises(); 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.lock).toHaveBeenCalledOnce();
expect(mocks.modalApi.close).not.toHaveBeenCalled(); expect(mocks.modalApi.close).not.toHaveBeenCalled();
expect(wrapper.emitted('saved')).toBeUndefined(); expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
expect(mocks.create).toHaveBeenNthCalledWith(1, expectedPayload);
mocks.create.mockResolvedValue({}); mocks.create.mockResolvedValue({});
await mocks.modalOptions.onConfirm(); await mocks.modalOptions.onConfirm();
@ -462,5 +487,22 @@ describe('message subscription modal', () => {
expect(mocks.modalApi.close).toHaveBeenCalledOnce(); expect(mocks.modalApi.close).toHaveBeenCalledOnce();
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.emitted('saved')).toHaveLength(1);
expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2); 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({ const [Modal, modalApi] = useVbenModal({
class: 'w-[680px]', class: 'w-[680px]',
fullscreenButton: false, 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() { async onConfirm() {
try {
await submit(); await submit();
} catch {
// The request/form layer already presents the persistence error.
}
}, },
/** /**
* Restores session values after destroy-on-close modal content has mounted. * 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); expect(mocks.update).toHaveBeenCalledWith('10000000000000001', payload);
}); });
it('stays open/unlocked after failure and closes/emits once on retry success', async () => { it('does not persist or lock when normal form validation fails', async () => {
mocks.create.mockRejectedValueOnce(new Error('save failed')); mocks.formApi.validate.mockResolvedValueOnce({ valid: false });
const wrapper = mountModal(); const wrapper = mountModal();
(wrapper.vm as any).openCreate(); (wrapper.vm as any).openCreate();
await flushPromises(); 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.lock).toHaveBeenCalledOnce();
expect(mocks.modalApi.close).not.toHaveBeenCalled(); expect(mocks.modalApi.close).not.toHaveBeenCalled();
expect(wrapper.emitted('saved')).toBeUndefined(); expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
expect(mocks.create).toHaveBeenNthCalledWith(1, expectedPayload);
mocks.create.mockResolvedValueOnce({}); mocks.create.mockResolvedValueOnce({});
await mocks.modalOptions.onConfirm(); await mocks.modalOptions.onConfirm();
expect(mocks.modalApi.close).toHaveBeenCalledOnce(); expect(mocks.modalApi.close).toHaveBeenCalledOnce();
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.emitted('saved')).toHaveLength(1);
expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2); 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({ const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]', class: 'w-[760px]',
fullscreenButton: false, 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() { async onConfirm() {
try {
await submit(); await submit();
} catch {
// The request/form layer already presents the persistence error.
}
}, },
/** /**
* Resets every mounted form/session state before loading authoritative detail. * Resets every mounted form/session state before loading authoritative detail.