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. */
|
||||
function mountModal(selfId = '10000000000000001') {
|
||||
return mount(AccountMessagePushModal, {
|
||||
@ -537,6 +546,76 @@ describe('account message-push modal', () => {
|
||||
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 () => {
|
||||
mocks.create.mockRejectedValueOnce(new Error('save failed'));
|
||||
const wrapper = mountModal();
|
||||
|
||||
@ -191,13 +191,16 @@ export default defineComponent({
|
||||
async function submit() {
|
||||
const revision = sessionRevision;
|
||||
const selfId = sessionSelfId;
|
||||
const currentEditingId = editingId.value;
|
||||
if (!selfId || selfId !== props.selfId) return;
|
||||
const { valid } = await formApi.validate();
|
||||
if (revision !== sessionRevision || selfId !== props.selfId) return;
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues<AccountMessagePushFormValues>();
|
||||
if (revision !== sessionRevision || selfId !== props.selfId) return;
|
||||
const payload = normalizeBindingPayload(props, values);
|
||||
if (!payload) return;
|
||||
const currentEditingId = editingId.value;
|
||||
if (revision !== sessionRevision || selfId !== props.selfId) return;
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
|
||||
@ -380,6 +380,58 @@ describe('account message-push panel', () => {
|
||||
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 () => {
|
||||
const wrapper = mountPanel('10001');
|
||||
await flushPromises();
|
||||
|
||||
@ -47,6 +47,9 @@ const PERMISSIONS = {
|
||||
update: 'QqBot:Account:MessagePush:Update',
|
||||
} as const;
|
||||
|
||||
const METADATA_MAX_PAGES = 100;
|
||||
const METADATA_PAGE_SIZE = 100;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AccountMessagePushPanel',
|
||||
props: {
|
||||
@ -272,10 +275,20 @@ export default defineComponent({
|
||||
}) => Promise<QqbotMessagePushApi.PageResult<Row>>,
|
||||
): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let pageNo = 1; pageNo <= 1000; pageNo += 1) {
|
||||
const page = await loader({ pageNo, pageSize: 100 });
|
||||
for (let pageNo = 1; pageNo <= METADATA_MAX_PAGES; pageNo += 1) {
|
||||
const page = await loader({
|
||||
pageNo,
|
||||
pageSize: METADATA_PAGE_SIZE,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user