fix(redeem): preserve success when account refresh fails

This commit is contained in:
chihao.ou
2026-09-12 13:50:12 +08:00
parent 4726bdd08b
commit bb0b50d8bc
4 changed files with 133 additions and 1 deletions
@@ -756,6 +756,7 @@ export default {
days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
userRefreshFailed: 'Redeemed successfully, but failed to refresh account information.',
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.',
pleaseEnterCode: 'Please enter a redeem code'
},
@@ -760,6 +760,7 @@ export default {
days: '天',
codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。',
userRefreshFailed: '兑换成功,但账户信息刷新失败。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。',
pleaseEnterCode: '请输入兑换码'
},
+6 -1
View File
@@ -447,7 +447,12 @@ const handleRedeem = async () => {
redeemResult.value = result
// Refresh user data to get updated balance/concurrency
await authStore.refreshUser()
try {
await authStore.refreshUser()
} catch (error) {
console.error('Failed to refresh user after redeem:', error)
appStore.showWarning(t('redeem.userRefreshFailed'))
}
// If subscription type, immediately refresh subscription status
if (result.type === 'subscription') {
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import RedeemView from '../RedeemView.vue'
const { redeem, getHistory, refreshUser, fetchActiveSubscriptions, showError, showWarning, showSuccess } = vi.hoisted(() => ({
redeem: vi.fn(),
getHistory: vi.fn(),
refreshUser: vi.fn(),
fetchActiveSubscriptions: vi.fn(),
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
redeemAPI: { redeem, getHistory },
authAPI: { getPublicSettings: vi.fn().mockResolvedValue({}) },
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ user: { balance: 10, concurrency: 2 }, refreshUser }),
}))
vi.mock('@/stores/subscriptions', () => ({
useSubscriptionStore: () => ({ fetchActiveSubscriptions }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showError, showWarning, showSuccess }),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return { ...actual, useI18n: () => ({ t: (key: string) => key }) }
})
async function submitCode() {
const wrapper = mount(RedeemView, {
global: { stubs: { AppLayout: { template: '<div><slot /></div>' }, Icon: true } },
})
await flushPromises()
await wrapper.get('input#code').setValue(' REDEEM-CODE ')
await wrapper.get('form').trigger('submit')
await flushPromises()
return wrapper
}
describe('RedeemView refresh after redemption', () => {
beforeEach(() => {
vi.clearAllMocks()
redeem.mockResolvedValue({ type: 'balance', value: 20, message: 'Code applied' })
getHistory.mockResolvedValue([])
refreshUser.mockResolvedValue({ balance: 30, concurrency: 2 })
fetchActiveSubscriptions.mockResolvedValue([])
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it.each(['balance', 'concurrency', 'subscription'])(
'keeps a successful %s redemption when profile refresh fails', async (type) => {
redeem.mockResolvedValue({ type, value: 20, message: 'Code applied' })
refreshUser.mockRejectedValue({ status: 503, message: 'Service unavailable' })
getHistory.mockResolvedValueOnce([]).mockResolvedValueOnce([{
id: 1, code: 'REDEEM-CODE', type, value: 20, used_at: '2026-03-08T00:00:00Z',
}])
const wrapper = await submitCode()
expect(redeem).toHaveBeenCalledWith('REDEEM-CODE')
expect(showError).not.toHaveBeenCalled()
expect(showWarning).toHaveBeenCalledWith('redeem.userRefreshFailed')
expect(showSuccess).toHaveBeenCalledWith('redeem.codeRedeemSuccess')
expect(wrapper.text()).toContain('Code applied')
expect(wrapper.text()).not.toContain('redeem.failedToRedeem')
expect((wrapper.get('input#code').element as HTMLInputElement).value).toBe('')
expect((wrapper.get('input#code').element as HTMLInputElement).disabled).toBe(false)
expect(getHistory).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('REDEEM-C...')
if (type === 'subscription') {
expect(fetchActiveSubscriptions).toHaveBeenCalledWith(true)
} else {
expect(fetchActiveSubscriptions).not.toHaveBeenCalled()
}
wrapper.unmount()
}
)
it('finishes normally without a warning when profile refresh succeeds', async () => {
const wrapper = await submitCode()
expect(refreshUser).toHaveBeenCalledOnce()
expect(showWarning).not.toHaveBeenCalled()
expect(showError).not.toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalledWith('redeem.codeRedeemSuccess')
expect((wrapper.get('input#code').element as HTMLInputElement).value).toBe('')
wrapper.unmount()
})
it('preserves the existing subscription refresh warning after successful redemption', async () => {
redeem.mockResolvedValue({ type: 'subscription', value: 20, message: 'Code applied' })
fetchActiveSubscriptions.mockRejectedValue(new Error('Network Error'))
const wrapper = await submitCode()
expect(showWarning).toHaveBeenCalledWith('redeem.subscriptionRefreshFailed')
expect(showError).not.toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalledWith('redeem.codeRedeemSuccess')
expect(getHistory).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('keeps the code and reports failure when the redemption request itself fails', async () => {
redeem.mockRejectedValue({ response: { data: { detail: 'Invalid code' } } })
const wrapper = await submitCode()
expect(showError).toHaveBeenCalledWith('redeem.redeemFailed')
expect(wrapper.text()).toContain('Invalid code')
expect(wrapper.text()).not.toContain('Code applied')
expect((wrapper.get('input#code').element as HTMLInputElement).value).toBe(' REDEEM-CODE ')
expect(refreshUser).not.toHaveBeenCalled()
expect(fetchActiveSubscriptions).not.toHaveBeenCalled()
expect(getHistory).toHaveBeenCalledOnce()
expect(showSuccess).not.toHaveBeenCalled()
expect(showWarning).not.toHaveBeenCalled()
wrapper.unmount()
})
})