Merge pull request #7493 from ouchihao/codex/bug-temp-unsched-race

fix(accounts): keep temporary unschedulable status scoped to the current account
This commit is contained in:
Wesley Liddick
2026-09-23 10:03:22 +08:00
committed by GitHub
2 changed files with 59 additions and 3 deletions
@@ -175,6 +175,7 @@ const appStore = useAppStore()
const loading = ref(false)
const resetting = ref(false)
const status = ref<TempUnschedulableStatus | null>(null)
let requestVersion = 0
const state = computed(() => status.value?.state || null)
@@ -236,14 +237,18 @@ const remainingText = computed(() => {
const loadStatus = async () => {
if (!props.account) return
const version = ++requestVersion
status.value = null
loading.value = true
try {
status.value = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id)
const result = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id)
if (version === requestVersion) status.value = result
} catch (error: any) {
if (version !== requestVersion) return
appStore.showError(error?.message || t('admin.accounts.tempUnschedulable.failedToLoad'))
status.value = null
} finally {
loading.value = false
if (version === requestVersion) loading.value = false
}
}
@@ -268,7 +273,8 @@ const handleReset = async () => {
watch(
() => [props.show, props.account?.id],
([visible]) => {
([visible], _, onCleanup) => {
onCleanup(() => { requestVersion++ })
if (visible && props.account) {
loadStatus()
return
@@ -0,0 +1,50 @@
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import TempUnschedStatusModal from '../TempUnschedStatusModal.vue'
import type { Account } from '@/types'
const mocks = vi.hoisted(() => ({ getTempUnschedulableStatus: vi.fn(), recoverState: vi.fn(), showError: vi.fn() }))
vi.mock('@/api/admin', () => ({ adminAPI: { accounts: mocks } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => mocks }))
vi.mock('@/utils/format', () => ({ formatDateTime: () => 'date' }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
enableAutoUnmount(afterEach)
beforeEach(() => vi.clearAllMocks())
function deferred() {
let resolve!: (value: unknown) => void
let reject!: (value: unknown) => void
const promise = new Promise((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
const active = (message: string) => ({ active: true, state: { until_unix: Date.now() / 1000 + 3600, error_message: message, rule_index: -1 } })
async function open() {
const w = mount(TempUnschedStatusModal, { props: { show: false, account: { id: 1, name: 'first' } as Account },
global: { stubs: { BaseDialog: { props: ['show'], template: '<div v-if="show"><slot /><slot name="footer" /></div>' } } } })
await w.setProps({ show: true }); return w
}
describe('temporary unschedulable status requests', () => {
it('does not let a late response replace the next account status', async () => {
const old = deferred()
mocks.getTempUnschedulableStatus.mockReturnValueOnce(old.promise).mockResolvedValueOnce(active('current-error'))
const w = await open(); await w.setProps({ account: { id: 2, name: 'second' } as Account }); await flushPromises()
old.resolve(active('old-error')); await flushPromises()
expect(w.text()).toContain('current-error'); expect(w.text()).not.toContain('old-error')
})
it('keeps recovery disabled while loading a different account', async () => {
const current = deferred()
mocks.getTempUnschedulableStatus.mockResolvedValueOnce(active('old-error')).mockReturnValueOnce(current.promise)
const w = await open(); await flushPromises()
await w.setProps({ account: { id: 2 } as Account })
expect(w.get('button.btn-primary').attributes('disabled')).toBeDefined()
current.resolve(active('current-error')); await flushPromises()
expect(w.get('button.btn-primary').attributes('disabled')).toBeUndefined()
})
it('ignores old errors without dismissing the current loading state', async () => {
const old = deferred(); const current = deferred()
mocks.getTempUnschedulableStatus.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise)
const w = await open(); await w.setProps({ show: false }); await w.setProps({ show: true })
old.reject(new Error('obsolete')); await flushPromises()
expect(mocks.showError).not.toHaveBeenCalled(); expect(w.find('.animate-spin').exists()).toBe(true)
current.resolve({ active: false }); await flushPromises()
expect(w.text()).toContain('admin.accounts.tempUnschedulable.notActive')
})
})