From 8a495b0a54f5202986f56e793ff741dc3f013c12 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 30 Sep 2026 02:45:47 +0800 Subject: [PATCH] fix(usage): ignore obsolete cleanup task list responses --- .../admin/usage/UsageCleanupDialog.vue | 9 ++- .../__tests__/UsageCleanupDialog.spec.ts | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts diff --git a/frontend/src/components/admin/usage/UsageCleanupDialog.vue b/frontend/src/components/admin/usage/UsageCleanupDialog.vue index 3218be30cc..136510a0af 100644 --- a/frontend/src/components/admin/usage/UsageCleanupDialog.vue +++ b/frontend/src/components/admin/usage/UsageCleanupDialog.vue @@ -155,6 +155,7 @@ const cancelConfirmVisible = ref(false) const canceling = ref(false) const cancelTarget = ref(null) let pollTimer: number | null = null +let taskRequestId = 0 const noop = () => {} @@ -183,6 +184,7 @@ const stopPolling = () => { } const handleClose = () => { + taskRequestId++ stopPolling() confirmVisible.value = false cancelConfirmVisible.value = false @@ -237,12 +239,14 @@ const getUserTimezone = () => { const loadTasks = async () => { if (!props.show) return + const requestId = ++taskRequestId tasksLoading.value = true try { const res = await adminUsageAPI.listCleanupTasks({ page: tasksPage.value, page_size: tasksPageSize.value }) + if (requestId !== taskRequestId) return tasks.value = res.items || [] tasksTotal.value = res.total || 0 if (res.page) { @@ -252,10 +256,11 @@ const loadTasks = async () => { tasksPageSize.value = res.page_size } } catch (error) { + if (requestId !== taskRequestId) return console.error('Failed to load cleanup tasks:', error) appStore.showError(t('admin.usage.cleanup.loadFailed')) } finally { - tasksLoading.value = false + if (requestId === taskRequestId) tasksLoading.value = false } } @@ -376,12 +381,14 @@ watch( loadTasks() startPolling() } else { + taskRequestId++ stopPolling() } } ) onUnmounted(() => { + taskRequestId++ stopPolling() }) diff --git a/frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts b/frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts new file mode 100644 index 0000000000..70f526af4e --- /dev/null +++ b/frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import UsageCleanupDialog from '../UsageCleanupDialog.vue' +import Pagination from '@/components/common/Pagination.vue' + +const { listCleanupTasks, showError } = vi.hoisted(() => ({ listCleanupTasks: vi.fn(), showError: vi.fn() })) +vi.mock('@/components/admin/usage/UsageFilters.vue', () => ({ default: { template: '
' } })) +vi.mock('@/api/admin/usage', () => ({ adminUsageAPI: { listCleanupTasks } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError }) })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) +beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, 'error').mockImplementation(() => {}) }) +afterEach(() => { vi.restoreAllMocks() }) +const page = (number: number) => ({ items: [{ id: number, status: 'succeeded', deleted_rows: number, filters: {} }], total: 15, page: number, page_size: 5 }) +async function openDialog() { + listCleanupTasks.mockResolvedValueOnce(page(1)) + const wrapper = mount(UsageCleanupDialog, { + props: { show: false, filters: {}, startDate: '2026-09-01', endDate: '2026-09-02' }, + global: { stubs: { BaseDialog: { template: '
' }, UsageFilters: true, ConfirmDialog: true, Pagination: true } }, + }) + await wrapper.setProps({ show: true }) + await flushPromises() + return wrapper +} + +describe('cleanup task pagination', () => { + it.each(['success', 'failure'])('ignores an older page request that completes with %s', async (outcome) => { + const wrapper = await openDialog() + let resolve!: (value: object) => void + let reject!: (error: Error) => void + listCleanupTasks.mockImplementationOnce(() => new Promise((res, rej) => { resolve = res; reject = rej })) + wrapper.findComponent(Pagination).vm.$emit('update:page', 2) + await flushPromises() + listCleanupTasks.mockResolvedValueOnce(page(3)) + wrapper.findComponent(Pagination).vm.$emit('update:page', 3) + await flushPromises() + if (outcome === 'success') resolve(page(2)) + else reject(new Error('old failure')) + await flushPromises() + expect(wrapper.findComponent(Pagination).props('page')).toBe(3) + expect(wrapper.text()).toContain('#3') + expect(showError).not.toHaveBeenCalled() + }) + + it('ignores a pending result after the dialog is closed and reopened', async () => { + const wrapper = await openDialog() + let resolve!: (value: object) => void + listCleanupTasks.mockImplementationOnce(() => new Promise(res => { resolve = res })) + wrapper.findComponent(Pagination).vm.$emit('update:page', 2) + await flushPromises() + await wrapper.setProps({ show: false }) + listCleanupTasks.mockResolvedValueOnce(page(1)) + await wrapper.setProps({ show: true }) + await flushPromises() + resolve(page(2)) + await flushPromises() + expect(wrapper.findComponent(Pagination).props('page')).toBe(1) + expect(wrapper.text()).toContain('#1') + }) +})