Merge pull request #7717 from ouchihao/codex/bug-payment-countdown-drift

fix(payment): calculate QR countdowns from elapsed time
This commit is contained in:
Wesley Liddick
2026-10-12 14:37:11 +08:00
committed by GitHub
4 changed files with 74 additions and 3 deletions
@@ -234,12 +234,13 @@ async function tryRecoverPendingOrder(order: PaymentOrder): Promise<PaymentOrder
function startCountdown(seconds: number) {
remainingSeconds.value = Math.max(0, seconds)
const deadline = Date.now() + remainingSeconds.value * 1000
if (remainingSeconds.value <= 0) {
expired.value = true
return
}
countdownTimer = setInterval(() => {
remainingSeconds.value--
remainingSeconds.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
if (remainingSeconds.value <= 0) {
expired.value = true
cleanup()
@@ -450,9 +450,10 @@ async function pollStatus() {
function startCountdown(seconds: number) {
remainingSeconds.value = Math.max(0, seconds)
const deadline = Date.now() + remainingSeconds.value * 1000
if (remainingSeconds.value <= 0) { setOutcome('expired'); return }
countdownTimer = setInterval(() => {
remainingSeconds.value--
remainingSeconds.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
if (remainingSeconds.value <= 0) { setOutcome('expired'); cleanup() }
}, 1000)
}
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import PaymentQRDialog from '../PaymentQRDialog.vue'
import PaymentStatusPanel from '../PaymentStatusPanel.vue'
import PaymentQRCodeView from '@/views/user/PaymentQRCodeView.vue'
vi.mock('vue-i18n', async (importOriginal) => ({
...await importOriginal<typeof import('vue-i18n')>(),
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/components/layout/AppLayout.vue', () => ({
default: { template: '<div><slot /></div>' },
}))
vi.mock('@/stores/payment', () => ({
usePaymentStore: () => ({ pollOrderStatus: vi.fn().mockResolvedValue(null) }),
}))
vi.mock('@/stores', () => ({ useAppStore: () => ({ showError: vi.fn() }) }))
vi.mock('@/api/payment', () => ({ paymentAPI: {} }))
vi.mock('qrcode', () => ({ default: { toCanvas: vi.fn() } }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: { order_id: '42', expires_at: new Date(Date.now() + 120_000).toISOString() } }),
useRouter: () => ({ push: vi.fn() }),
}))
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-30T12:00:00Z'))
})
afterEach(() => vi.useRealTimers())
async function open(kind: 'dialog' | 'panel' | 'page') {
const props = { orderId: 42, qrCode: '', expiresAt: new Date(Date.now() + 120_000).toISOString(), paymentType: 'custom' }
const global = { stubs: {
Icon: true,
AppLayout: { template: '<div><slot /></div>' },
BaseDialog: { props: ['show'], template: '<div v-if="show"><slot /><slot name="footer" /></div>' },
} }
if (kind === 'page') return mount(PaymentQRCodeView, { global })
if (kind === 'panel') return mount(PaymentStatusPanel, { props, global })
const wrapper = mount(PaymentQRDialog, { props: { ...props, show: false }, global })
await wrapper.setProps({ show: true })
return wrapper
}
describe.each(['dialog', 'panel', 'page'] as const)('payment countdown: %s', (kind) => {
it('catches up after timers were suspended', async () => {
const wrapper = await open(kind)
expect(wrapper.text()).toContain('02:00')
vi.setSystemTime(Date.now() + 65_000)
await vi.advanceTimersByTimeAsync(1000)
expect(wrapper.text()).toContain('00:54')
})
it('expires and stops polling on the first overdue tick', async () => {
const wrapper = await open(kind)
vi.setSystemTime(Date.now() + 121_000)
await vi.advanceTimersByTimeAsync(1000)
expect(wrapper.text()).toContain('payment.qr.expired')
expect(vi.getTimerCount()).toBe(0)
})
it('still counts down normally', async () => {
const wrapper = await open(kind)
await vi.advanceTimersByTimeAsync(1000)
expect(wrapper.text()).toContain('01:59')
})
})
@@ -157,12 +157,13 @@ async function pollStatus() {
function startCountdown(seconds: number) {
remainingSeconds.value = Math.max(0, seconds)
const deadline = Date.now() + remainingSeconds.value * 1000
if (remainingSeconds.value <= 0) {
expired.value = true
return
}
countdownTimer = setInterval(() => {
remainingSeconds.value--
remainingSeconds.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
if (remainingSeconds.value <= 0) {
expired.value = true
cleanup()