fix: 修复账号推送异步竞态与分页边界
This commit is contained in:
parent
d65be70048
commit
c67fc1b3de
@ -283,6 +283,15 @@ function createBinding(): QqbotMessagePushApi.QqbotMessagePublishBindingView {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Creates one manually resolved promise for async session-race assertions. */
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
/** Mounts the modal with one implicit publisher and page-owned metadata. */
|
/** Mounts the modal with one implicit publisher and page-owned metadata. */
|
||||||
function mountModal(selfId = '10000000000000001') {
|
function mountModal(selfId = '10000000000000001') {
|
||||||
return mount(AccountMessagePushModal, {
|
return mount(AccountMessagePushModal, {
|
||||||
@ -537,6 +546,76 @@ describe('account message-push modal', () => {
|
|||||||
expect(mocks.update).not.toHaveBeenCalled();
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not persist or close a new-account session after old validation resumes', async () => {
|
||||||
|
const validation = deferred<{ valid: boolean }>();
|
||||||
|
mocks.formApi.validate.mockReturnValueOnce(validation.promise);
|
||||||
|
const wrapper = mountModal();
|
||||||
|
(wrapper.vm as any).openCreate();
|
||||||
|
await flushPromises();
|
||||||
|
mocks.currentValues = {
|
||||||
|
enabled: true,
|
||||||
|
subscriptionId: '20000000000000001',
|
||||||
|
targets: [{ targetId: '12345', targetType: 'group' }],
|
||||||
|
templateId: '50000000000000001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const staleConfirmation = mocks.modalOptions.onConfirm();
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.setProps({ selfId: '10000000000000002' });
|
||||||
|
await flushPromises();
|
||||||
|
(wrapper.vm as any).openCreate();
|
||||||
|
await flushPromises();
|
||||||
|
mocks.currentValues = {
|
||||||
|
enabled: true,
|
||||||
|
subscriptionId: '20000000000000001',
|
||||||
|
targets: [{ targetId: '54321', targetType: 'private' }],
|
||||||
|
templateId: '50000000000000001',
|
||||||
|
};
|
||||||
|
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.close).toHaveBeenCalledTimes(closeCountBeforeResume);
|
||||||
|
expect(mocks.modalApi.setData).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ selfId: '10000000000000002' }),
|
||||||
|
);
|
||||||
|
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not borrow a newer same-account edit identity after validation resumes', async () => {
|
||||||
|
const validation = deferred<{ valid: boolean }>();
|
||||||
|
mocks.formApi.validate.mockReturnValueOnce(validation.promise);
|
||||||
|
const wrapper = mountModal();
|
||||||
|
(wrapper.vm as any).openCreate();
|
||||||
|
await flushPromises();
|
||||||
|
mocks.currentValues = {
|
||||||
|
enabled: true,
|
||||||
|
subscriptionId: '20000000000000001',
|
||||||
|
targets: [{ targetId: '12345', targetType: 'group' }],
|
||||||
|
templateId: '50000000000000001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const staleConfirmation = mocks.modalOptions.onConfirm();
|
||||||
|
await flushPromises();
|
||||||
|
(wrapper.vm as any).openEdit(createBinding());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
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.close).not.toHaveBeenCalled();
|
||||||
|
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a rejected session open and closes/emits exactly once on success', async () => {
|
it('keeps a rejected session open and closes/emits exactly once on success', async () => {
|
||||||
mocks.create.mockRejectedValueOnce(new Error('save failed'));
|
mocks.create.mockRejectedValueOnce(new Error('save failed'));
|
||||||
const wrapper = mountModal();
|
const wrapper = mountModal();
|
||||||
|
|||||||
@ -191,13 +191,16 @@ export default defineComponent({
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
const revision = sessionRevision;
|
const revision = sessionRevision;
|
||||||
const selfId = sessionSelfId;
|
const selfId = sessionSelfId;
|
||||||
|
const currentEditingId = editingId.value;
|
||||||
if (!selfId || selfId !== props.selfId) return;
|
if (!selfId || selfId !== props.selfId) return;
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
|
if (revision !== sessionRevision || selfId !== props.selfId) return;
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
const values = await formApi.getValues<AccountMessagePushFormValues>();
|
const values = await formApi.getValues<AccountMessagePushFormValues>();
|
||||||
|
if (revision !== sessionRevision || selfId !== props.selfId) return;
|
||||||
const payload = normalizeBindingPayload(props, values);
|
const payload = normalizeBindingPayload(props, values);
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const currentEditingId = editingId.value;
|
if (revision !== sessionRevision || selfId !== props.selfId) return;
|
||||||
|
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -380,6 +380,58 @@ describe('account message-push panel', () => {
|
|||||||
expect(modal.props('templates')).toHaveLength(101);
|
expect(modal.props('templates')).toHaveLength(101);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stops after a short page even when the reported total is stale and high', async () => {
|
||||||
|
mocks.api.getSubscriptions.mockResolvedValue({
|
||||||
|
items: [createSubscription()],
|
||||||
|
total: 100_000,
|
||||||
|
});
|
||||||
|
const wrapper = mountPanel();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce();
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.getComponent({ name: 'MockAccountMessagePushModal' })
|
||||||
|
.props('subscriptions'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops safely when a metadata page is empty', async () => {
|
||||||
|
mocks.api.getSubscriptions.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 100_000,
|
||||||
|
});
|
||||||
|
const wrapper = mountPanel();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce();
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.getComponent({ name: 'MockAccountMessagePushModal' })
|
||||||
|
.props('subscriptions'),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['NaN', Number.NaN],
|
||||||
|
['positive infinity', Number.POSITIVE_INFINITY],
|
||||||
|
['negative', -1],
|
||||||
|
])('stops safely for an invalid %s total', async (_label, total) => {
|
||||||
|
const items = Array.from({ length: 100 }, (_, index) =>
|
||||||
|
createSubscription(`2${String(index).padStart(16, '0')}`),
|
||||||
|
);
|
||||||
|
mocks.api.getSubscriptions.mockResolvedValue({ items, total });
|
||||||
|
const wrapper = mountPanel();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce();
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.getComponent({ name: 'MockAccountMessagePushModal' })
|
||||||
|
.props('subscriptions'),
|
||||||
|
).toHaveLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses a revision guard for real selfId changes and ignores stale rows', async () => {
|
it('uses a revision guard for real selfId changes and ignores stale rows', async () => {
|
||||||
const wrapper = mountPanel('10001');
|
const wrapper = mountPanel('10001');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|||||||
@ -47,6 +47,9 @@ const PERMISSIONS = {
|
|||||||
update: 'QqBot:Account:MessagePush:Update',
|
update: 'QqBot:Account:MessagePush:Update',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const METADATA_MAX_PAGES = 100;
|
||||||
|
const METADATA_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'AccountMessagePushPanel',
|
name: 'AccountMessagePushPanel',
|
||||||
props: {
|
props: {
|
||||||
@ -272,10 +275,20 @@ export default defineComponent({
|
|||||||
}) => Promise<QqbotMessagePushApi.PageResult<Row>>,
|
}) => Promise<QqbotMessagePushApi.PageResult<Row>>,
|
||||||
): Promise<Row[]> {
|
): Promise<Row[]> {
|
||||||
const rows: Row[] = [];
|
const rows: Row[] = [];
|
||||||
for (let pageNo = 1; pageNo <= 1000; pageNo += 1) {
|
for (let pageNo = 1; pageNo <= METADATA_MAX_PAGES; pageNo += 1) {
|
||||||
const page = await loader({ pageNo, pageSize: 100 });
|
const page = await loader({
|
||||||
|
pageNo,
|
||||||
|
pageSize: METADATA_PAGE_SIZE,
|
||||||
|
});
|
||||||
rows.push(...page.items);
|
rows.push(...page.items);
|
||||||
if (rows.length >= page.total || page.items.length === 0) break;
|
if (
|
||||||
|
!Number.isFinite(page.total) ||
|
||||||
|
page.total < 0 ||
|
||||||
|
rows.length >= page.total ||
|
||||||
|
page.items.length < METADATA_PAGE_SIZE
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user