From b51f0759d4a15bea6071d22387069969f73e3453 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Thu, 17 Sep 2026 14:58:46 +0800 Subject: [PATCH] fix(payment): await in-flight configuration requests --- .../stores/__tests__/payment.config.spec.ts | 78 +++++++++++++++++++ frontend/src/stores/payment.ts | 9 ++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 frontend/src/stores/__tests__/payment.config.spec.ts diff --git a/frontend/src/stores/__tests__/payment.config.spec.ts b/frontend/src/stores/__tests__/payment.config.spec.ts new file mode 100644 index 0000000000..70088fdfc9 --- /dev/null +++ b/frontend/src/stores/__tests__/payment.config.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { usePaymentStore } from '../payment' +import type { PaymentConfig } from '@/types/payment' + +const getConfig = vi.hoisted(() => vi.fn()) +vi.mock('@/api/payment', () => ({ paymentAPI: { getConfig } })) + +const config = { stripe_publishable_key: 'pk_test_example', payment_enabled: true } as PaymentConfig +function deferred() { + let resolve!: (value: { data: PaymentConfig }) => void + let reject!: (error: Error) => void + const promise = new Promise<{ data: PaymentConfig }>((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +beforeEach(() => { + setActivePinia(createPinia()) + getConfig.mockReset() + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => vi.restoreAllMocks()) + +describe('payment configuration requests', () => { + it('waits for the same response for concurrent initial callers', async () => { + const pending = deferred() + getConfig.mockReturnValue(pending.promise) + const store = usePaymentStore() + const first = store.fetchConfig() + let finished = false + const second = store.fetchConfig().then(value => { finished = true; return value }) + await Promise.resolve() + await Promise.resolve() + expect(finished).toBe(false) + expect(store.configLoading).toBe(true) + expect(getConfig).toHaveBeenCalledTimes(1) + pending.resolve({ data: config }) + expect(await Promise.all([first, second])).toEqual([config, config]) + expect(store.configLoading).toBe(false) + }) + + it('shares a forced refresh instead of returning stale cached configuration', async () => { + getConfig.mockResolvedValueOnce({ data: config }) + const store = usePaymentStore() + await store.fetchConfig() + const pending = deferred() + getConfig.mockReturnValueOnce(pending.promise) + const first = store.fetchConfig(true) + const second = store.fetchConfig(true) + const updated = { ...config, stripe_publishable_key: 'pk_test_updated' } + pending.resolve({ data: updated }) + expect(await Promise.all([first, second])).toEqual([updated, updated]) + expect(getConfig).toHaveBeenCalledTimes(2) + }) + + it('releases the shared request after failure so a later call can retry', async () => { + const pending = deferred() + getConfig.mockReturnValueOnce(pending.promise) + const store = usePaymentStore() + const first = store.fetchConfig() + const second = store.fetchConfig() + pending.reject(new Error('offline')) + expect(await Promise.all([first, second])).toEqual([null, null]) + expect(store.configLoading).toBe(false) + expect(store.configLoaded).toBe(false) + getConfig.mockResolvedValueOnce({ data: config }) + expect(await store.fetchConfig()).toEqual(config) + expect(getConfig).toHaveBeenCalledTimes(2) + }) + + it('returns cached configuration without another request', async () => { + getConfig.mockResolvedValue({ data: config }) + const store = usePaymentStore() + await store.fetchConfig() + expect(await store.fetchConfig()).toEqual(config) + expect(getConfig).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/stores/payment.ts b/frontend/src/stores/payment.ts index fc21c1f9fc..e88375bbea 100644 --- a/frontend/src/stores/payment.ts +++ b/frontend/src/stores/payment.ts @@ -20,14 +20,20 @@ export const usePaymentStore = defineStore('payment', () => { const configLoading = ref(false) const configLoaded = ref(false) + let configPromise: Promise | null = null // ==================== Actions ==================== /** Fetch payment configuration */ async function fetchConfig(force = false): Promise { if (configLoaded.value && !force) return config.value - if (configLoading.value) return config.value + if (configPromise) return configPromise + configPromise = loadConfig() + return configPromise + } + + async function loadConfig(): Promise { configLoading.value = true try { const response = await paymentAPI.getConfig() @@ -39,6 +45,7 @@ export const usePaymentStore = defineStore('payment', () => { return null } finally { configLoading.value = false + configPromise = null } }