fix: 隔离消息推送编辑会话

This commit is contained in:
sunlei 2026-07-25 09:17:01 +08:00
parent b42a393a41
commit 1d07233114
4 changed files with 314 additions and 107 deletions

View File

@ -46,7 +46,6 @@ const mocks = vi.hoisted(() => {
};
});
/** Creates a fluent no-op validation rule for schema-only unit tests. */
function createRule(): any {
const rule: any = {};
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
@ -60,7 +59,6 @@ vi.mock('#/adapter/form', () => ({
mocks.formOptions = options;
const Form = defineComponent({
name: 'MockForm',
/** Renders a stable form marker without owning form state. */
setup() {
return () => h('form', { 'data-testid': 'subscription-form' });
},
@ -78,7 +76,6 @@ vi.mock('@vben/common-ui', () => ({
mocks.modalOptions = options;
const Modal = defineComponent({
name: 'MockModal',
/** Renders modal content while the mock API controls lifecycle calls. */
setup(_, { slots }) {
return () => h('section', slots.default?.());
},
@ -92,7 +89,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
updateMessageSubscription: mocks.update,
}));
/** Creates the immutable source catalog fixture consumed by the modal. */
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
return [
{
@ -106,7 +102,6 @@ function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
];
}
/** Creates string-ID STUN choices including disabled API-owned reasons. */
function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse {
return {
ddnsRecords: [
@ -158,7 +153,6 @@ function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsR
};
}
/** Creates one editable row with only the global subscription contract. */
function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
return {
createTime: '2026-07-24 10:00:00',
@ -179,7 +173,14 @@ function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
};
}
/** Mounts the modal with page-owned source and STUN metadata. */
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function mountModal() {
return mount(MessageSubscriptionModal, {
props: {
@ -265,6 +266,151 @@ describe('message subscription modal', () => {
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();
const staleConfirmation = mocks.modalOptions.onConfirm();
await flushPromises();
const editRow = {
...createRow(),
enabled: false,
id: '10000000000000002',
name: '新编辑订阅',
remark: '新编辑备注',
};
(wrapper.vm as any).openEdit(editRow);
await flushPromises();
const editValues = {
ddnsRecordId: '2041700000000000002',
enabled: false,
name: ' 新编辑订阅 ',
portForwardId: '2041700000000000001',
remark: ' 新编辑备注 ',
sourceKey: 'network.stun.mapping-port-changed',
};
mocks.formApi.getValues.mockResolvedValue(editValues);
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.formApi.setValues).toHaveBeenLastCalledWith({
ddnsRecordId: '2041700000000000002',
enabled: false,
name: '新编辑订阅',
portForwardId: '2041700000000000001',
remark: '新编辑备注',
sourceKey: 'network.stun.mapping-port-changed',
});
await mocks.modalOptions.onConfirm();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).toHaveBeenCalledOnce();
expect(mocks.update).toHaveBeenCalledWith('10000000000000002', {
enabled: false,
name: '新编辑订阅',
remark: '新编辑备注',
sourceConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
sourceKey: 'network.stun.mapping-port-changed',
});
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<{
ddnsRecordId: string;
enabled: boolean;
name: string;
portForwardId: 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();
const createValues = {
ddnsRecordId: '2041700000000000002',
enabled: true,
name: ' 新创建订阅 ',
portForwardId: '2041700000000000001',
remark: ' 新创建备注 ',
sourceKey: 'network.stun.mapping-port-changed',
};
mocks.formApi.getValues.mockResolvedValue(createValues);
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
values.resolve({
ddnsRecordId: '2041700000000002',
enabled: false,
name: '旧编辑订阅',
portForwardId: '2041700000000001',
remark: '旧编辑备注',
sourceKey: 'network.stun.mapping-port-changed',
});
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.formApi.setValues).toHaveBeenLastCalledWith({
ddnsRecordId: undefined,
enabled: true,
name: '',
portForwardId: undefined,
remark: '',
sourceKey: 'network.stun.mapping-port-changed',
});
await mocks.modalOptions.onConfirm();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.create).toHaveBeenCalledOnce();
expect(mocks.create).toHaveBeenCalledWith({
enabled: true,
name: '新创建订阅',
remark: '新创建备注',
sourceConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
sourceKey: 'network.stun.mapping-port-changed',
});
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
closeCountBeforeResume + 1,
);
expect(wrapper.emitted('saved')).toHaveLength(1);
});
it('preserves string IDs and exposes disabled reasons on matching options', async () => {
const wrapper = mountModal();
(wrapper.vm as any).openEdit(createRow());

View File

@ -48,21 +48,14 @@ export default defineComponent({
},
},
emits: ['saved'],
/**
* Owns one source-only create/edit form without fetching page metadata or lists.
*/
setup(props, { emit, expose }) {
const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>();
const selectedPortForwardId = ref<string>();
let sessionRevision = 0;
const [SubscriptionForm, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
/**
* Keeps the DDNS choice compatible with the selected port-forward option.
* @param values - Current form values after the change.
* @param fieldsChanged - Field names changed by this form event.
*/
async handleValuesChange(values, fieldsChanged) {
if (!fieldsChanged.includes('portForwardId')) return;
selectedPortForwardId.value =
@ -95,10 +88,6 @@ export default defineComponent({
const [Modal, modalApi] = useVbenModal({
class: 'w-[680px]',
fullscreenButton: false,
/**
* Persists the current session while containing failures at ModalApi's
* non-awaited callback boundary.
*/
async onConfirm() {
try {
await submit();
@ -106,10 +95,6 @@ export default defineComponent({
// The request/form layer already presents the persistence error.
}
},
/**
* Restores session values after destroy-on-close modal content has mounted.
* @param isOpen - Whether the modal content is visible.
*/
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = modalApi.getData<MessageSubscriptionModalData>();
@ -118,8 +103,8 @@ export default defineComponent({
},
});
/** Opens a fresh subscription form with no values retained from an edit. */
function openCreate() {
sessionRevision += 1;
editingRow.value = undefined;
modalApi
.setData({
@ -135,11 +120,8 @@ export default defineComponent({
.open();
}
/**
* Opens an edit session with only the six user-editable values.
* @param row - Subscription row selected from the page-owned KtTable.
*/
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
sessionRevision += 1;
editingRow.value = row;
modalApi
.setData({
@ -155,21 +137,20 @@ export default defineComponent({
.open();
}
/**
* Resets the mounted form before installing one isolated modal session.
* @param values - Exact editable values for the current create/edit session.
*/
async function resetForm(values: MessageSubscriptionFormValues) {
await formApi.resetForm();
await formApi.setValues(values);
await formApi.resetValidate();
}
/** Persists the exact source-only payload and closes only after success. */
async function submit() {
const revision = sessionRevision;
const editingId = editingRow.value?.id;
const { valid } = await formApi.validate();
if (revision !== sessionRevision) return;
if (!valid) return;
const values = await formApi.getValues<MessageSubscriptionFormValues>();
if (revision !== sessionRevision) return;
const payload: QqbotMessagePushApi.MessageSubscriptionInput = {
enabled: !!values.enabled,
name: values.name.trim(),
@ -180,12 +161,14 @@ export default defineComponent({
},
sourceKey: values.sourceKey,
};
if (revision !== sessionRevision) return;
modalApi.lock();
try {
await (editingRow.value
? updateMessageSubscription(editingRow.value.id, payload)
await (editingId
? updateMessageSubscription(editingId, payload)
: createMessageSubscription(payload));
if (revision !== sessionRevision) return;
await modalApi.close();
emit('saved');
} finally {
@ -203,12 +186,6 @@ export default defineComponent({
},
});
/**
* Builds the locked six-field schema from page-owned metadata.
* @param props - Source catalog and STUN choices loaded once by the page.
* @param selectedPortForwardId - Current port used to filter matching DDNS rows.
* @returns Vben form fields in the approved source-only order.
*/
function createFormSchema(
props: Readonly<{
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[];
@ -280,11 +257,6 @@ function createFormSchema(
];
}
/**
* Formats one server-evaluated port choice without coercing its string ID.
* @param option - Port-forward option returned by the source options API.
* @returns Select option preserving eligibility and stable reason code.
*/
function formatPortForwardOption(
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number],
) {
@ -298,11 +270,6 @@ function formatPortForwardOption(
};
}
/**
* Formats one server-evaluated DDNS choice without coercing its string ID.
* @param option - DDNS option already filtered to the selected port.
* @returns Select option preserving eligibility and stable reason code.
*/
function formatDdnsOption(
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number],
) {

View File

@ -49,7 +49,6 @@ const mocks = vi.hoisted(() => {
};
});
/** Creates a fluent no-op validation rule for schema behavior tests. */
function createRule(): any {
const rule: any = {};
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
@ -63,7 +62,6 @@ vi.mock('#/adapter/form', () => ({
mocks.formOptions = options;
const Form = defineComponent({
name: 'MockTemplateForm',
/** Renders the production schema's local content component with live props. */
setup() {
return () => {
const content = mocks.formOptions.schema.find(
@ -98,7 +96,6 @@ vi.mock('@vben/common-ui', () => ({
mocks.modalOptions = options;
const Modal = defineComponent({
name: 'MockTemplateModal',
/** Renders the actual form plus the explicit prepend-footer action slot. */
setup(_, { slots }) {
return () =>
h('section', { 'data-testid': 'template-modal' }, [
@ -118,7 +115,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
updateMessageTemplate: mocks.update,
}));
/** Creates one source with a distinguishable variable catalog. */
function createSource(
sourceKey = 'source-a',
variableKey = 'endpoint',
@ -141,7 +137,6 @@ function createSource(
};
}
/** Creates one unsafe-integer string-ID editable template row. */
function createRow(): QqbotMessagePushApi.MessageTemplateView {
return {
content: 'old [CQ:at,qq=12345]',
@ -157,7 +152,14 @@ function createRow(): QqbotMessagePushApi.MessageTemplateView {
};
}
/** Mounts the modal with page-owned source labels. */
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: {
@ -260,6 +262,137 @@ describe('message template modal', () => {
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,

View File

@ -53,7 +53,6 @@ export default defineComponent({
},
},
emits: ['saved'],
/** Owns one isolated template edit session, source detail cache, and preview. */
setup(props, { emit, expose }) {
const editingRow = ref<QqbotMessagePushApi.MessageTemplateView>();
const variables = ref<
@ -69,15 +68,11 @@ export default defineComponent({
>();
let sourceRevision = 0;
let previewRevision = 0;
let sessionRevision = 0;
const [TemplateForm, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
/**
* Clears stale preview state and refreshes variables only for source changes.
* @param values - Current form values after the update.
* @param fieldsChanged - Exact fields changed by this form event.
*/
async handleValuesChange(values, fieldsChanged) {
if (fieldsChanged.includes('content')) clearPreview();
if (!fieldsChanged.includes('sourceKey')) return;
@ -96,17 +91,12 @@ export default defineComponent({
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
/** Tracks the title from isolated create/edit identity only. */
const modalTitle = computed(() =>
editingRow.value ? '编辑消息模板' : '新建消息模板',
);
const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]',
fullscreenButton: false,
/**
* Persists the exact payload while containing failures at ModalApi's
* non-awaited callback boundary.
*/
async onConfirm() {
try {
await submit();
@ -114,10 +104,6 @@ export default defineComponent({
// The request/form layer already presents the persistence error.
}
},
/**
* Resets every mounted form/session state before loading authoritative detail.
* @param isOpen - Whether destroy-on-close modal content is mounted.
*/
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = modalApi.getData<MessageTemplateModalData>();
@ -128,8 +114,8 @@ export default defineComponent({
},
});
/** Opens a fresh create session without values from the preceding edit. */
function openCreate() {
sessionRevision += 1;
editingRow.value = undefined;
modalApi
.setData({
@ -144,11 +130,8 @@ export default defineComponent({
.open();
}
/**
* Opens an edit session with only the five user-editable values.
* @param row - Template row selected from the page-owned KtTable.
*/
function openEdit(row: QqbotMessagePushApi.MessageTemplateView) {
sessionRevision += 1;
editingRow.value = row;
modalApi
.setData({
@ -163,17 +146,12 @@ export default defineComponent({
.open();
}
/**
* Resets and installs the current session values in the required order.
* @param values - Exact editable values stored before the modal opened.
*/
async function resetForm(values: MessageTemplateFormValues) {
await formApi.resetForm();
await formApi.setValues(values);
await formApi.resetValidate();
}
/** Invalidates stale source/preview work and clears every transient display. */
function invalidateSession() {
sourceRevision += 1;
detailLoading.value = false;
@ -181,18 +159,12 @@ export default defineComponent({
clearPreview();
}
/** Clears authoritative preview data and invalidates any in-flight preview. */
function clearPreview() {
previewRevision += 1;
preview.value = undefined;
previewLoading.value = false;
}
/**
* Reuses an exact-key in-flight/resolved source detail promise.
* @param sourceKey - Stable exact source identity selected in the form.
* @returns Authoritative source detail promise, evicted when rejected.
*/
function getCachedSourceDetail(sourceKey: string) {
const cached = detailCache.get(sourceKey);
if (cached) return cached;
@ -206,11 +178,6 @@ export default defineComponent({
return request;
}
/**
* 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.
*/
async function loadSourceDetail(sourceKey: string) {
const revision = ++sourceRevision;
clearPreview();
@ -241,7 +208,6 @@ export default defineComponent({
}
}
/** Requests server-authoritative preview only after an explicit valid click. */
async function handlePreview() {
if (!props.canPreview) return;
const [sourceValidation, contentValidation] = await Promise.all([
@ -265,11 +231,14 @@ export default defineComponent({
}
}
/** Persists content byte-for-byte and closes/emits only after success. */
async function submit() {
const revision = sessionRevision;
const editingId = editingRow.value?.id;
const { valid } = await formApi.validate();
if (revision !== sessionRevision) return;
if (!valid) return;
const values = await formApi.getValues<MessageTemplateFormValues>();
if (revision !== sessionRevision) return;
const payload: QqbotMessagePushApi.MessageTemplateInput = {
content: values.content,
enabled: !!values.enabled,
@ -277,11 +246,13 @@ export default defineComponent({
remark: values.remark?.trim() || '',
sourceKey: values.sourceKey,
};
if (revision !== sessionRevision) return;
modalApi.lock();
try {
await (editingRow.value
? updateMessageTemplate(editingRow.value.id, payload)
await (editingId
? updateMessageTemplate(editingId, payload)
: createMessageTemplate(payload));
if (revision !== sessionRevision) return;
await modalApi.close();
emit('saved');
} finally {
@ -289,7 +260,6 @@ export default defineComponent({
}
}
/** Renders the permission-gated explicit preview action. */
function renderPreviewAction() {
return props.canPreview ? (
<AButton loading={previewLoading.value} onClick={handlePreview}>
@ -298,7 +268,6 @@ export default defineComponent({
) : null;
}
/** Renders server-returned preview text and variables as escaped text nodes. */
function renderPreview() {
const result = preview.value;
if (!result) return null;
@ -331,14 +300,6 @@ export default defineComponent({
},
});
/**
* Builds the exact template schema with a local controlled Mentions component.
* @param props - Page-owned source labels and preview permission.
* @param variables - Variables from the latest exact source detail.
* @param loading - Source-detail loading state forwarded to Mentions.
* @param selectedSourceKey - Current source identity used to disable no-source input.
* @returns Five fields in the locked source/name/content/enabled/remark order.
*/
function createFormSchema(
props: Readonly<{
canPreview: boolean;