diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx index e163f18..02d7b9f 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx @@ -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(); }); }); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx index 9bf2ca9..d810600 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx @@ -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. diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx index 6afad03..d5ea049 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx @@ -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(); }); }); diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx index 5b41a66..6d7d757 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx @@ -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. diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx index 3590b28..2c41f6d 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx @@ -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(); }); }); diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx index a6f2b0c..5d9bb26 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx @@ -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.