diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index 7dff831a6a..621d8f010d 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -234,12 +234,13 @@ async function tryRecoverPendingOrder(order: PaymentOrder): Promise { - remainingSeconds.value-- + remainingSeconds.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000)) if (remainingSeconds.value <= 0) { expired.value = true cleanup() diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index e751c42225..243ffac768 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -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) } diff --git a/frontend/src/components/payment/__tests__/paymentCountdown.spec.ts b/frontend/src/components/payment/__tests__/paymentCountdown.spec.ts new file mode 100644 index 0000000000..fec3892474 --- /dev/null +++ b/frontend/src/components/payment/__tests__/paymentCountdown.spec.ts @@ -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(), + useI18n: () => ({ t: (key: string) => key }), +})) +vi.mock('@/components/layout/AppLayout.vue', () => ({ + default: { template: '
' }, +})) +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: '
' }, + BaseDialog: { props: ['show'], template: '
' }, + } } + 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') + }) +}) diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue index 0569bf6907..b535b14682 100644 --- a/frontend/src/views/user/PaymentQRCodeView.vue +++ b/frontend/src/views/user/PaymentQRCodeView.vue @@ -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()