Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions src/composables/auth/useAuthActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const mockAuthStore = vi.hoisted(() => ({
loginWithGoogle: vi.fn().mockResolvedValue(undefined),
loginWithGithub: vi.fn().mockResolvedValue(undefined),
register: vi.fn().mockResolvedValue(undefined),
logout: vi.fn().mockResolvedValue(undefined)
logout: vi.fn().mockResolvedValue(undefined),
initiateCreditPurchase: vi.fn().mockResolvedValue({
checkout_url: 'https://checkout.stripe.test'
})
}))

const mockToastStore = vi.hoisted(() => ({
Expand All @@ -33,7 +36,11 @@ const mockDialogService = vi.hoisted(() => ({

const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockTrackAuthFailed = vi.hoisted(() => vi.fn())
const mockStartTopupTracking = vi.hoisted(() => vi.fn())
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockBillingState = vi.hoisted(() => ({
canAccessSubscriptionFeatures: false
}))
const mockClearAllWorkflowStorage = vi.hoisted(() => vi.fn())

const knownAuthErrorCodes = new Set([
Expand All @@ -59,7 +66,8 @@ vi.mock('@/platform/distribution/types', () => ({

vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAuthFailed: mockTrackAuthFailed
trackAuthFailed: mockTrackAuthFailed,
startTopupTracking: mockStartTopupTracking
}))
}))

Expand Down Expand Up @@ -89,7 +97,9 @@ vi.mock('@/stores/authStore', () => ({

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
canAccessSubscriptionFeatures: { value: false },
canAccessSubscriptionFeatures: {
value: mockBillingState.canAccessSubscriptionFeatures
},
isFreeTier: { value: true },
type: { value: 'free' }
}))
Expand Down Expand Up @@ -120,6 +130,52 @@ function makeWorkflow(path: string): ModifiedWorkflow {

beforeEach(() => {
mockDistributionState.isCloud = false
mockBillingState.canAccessSubscriptionFeatures = false
})

describe('useAuthActions.purchaseCreditsDirect', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockBillingState.canAccessSubscriptionFeatures = true
})

it('starts top-up tracking before opening Stripe checkout', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const { purchaseCreditsDirect } = useAuthActions()

await purchaseCreditsDirect(25)

expect(mockStartTopupTracking).toHaveBeenCalledOnce()
expect(open).toHaveBeenCalledWith('https://checkout.stripe.test', '_blank')
expect(mockStartTopupTracking.mock.invocationCallOrder[0]).toBeLessThan(
open.mock.invocationCallOrder[0]
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('does not start tracking or open checkout when no checkout URL is returned', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
const { purchaseCreditsDirect } = useAuthActions()

await expect(purchaseCreditsDirect(25)).rejects.toThrow()

expect(mockStartTopupTracking).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
})

it('does not start tracking or open checkout when the purchase request rejects', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
mockAuthStore.initiateCreditPurchase.mockRejectedValueOnce(
new Error('network down')
)
const { purchaseCreditsDirect } = useAuthActions()

await expect(purchaseCreditsDirect(25)).rejects.toThrow('network down')

expect(mockStartTopupTracking).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
})
})

describe('useAuthActions.logout', () => {
Expand Down
153 changes: 152 additions & 1 deletion src/platform/cloud/subscription/components/CreditsTile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const state = vi.hoisted(() => ({
showPricingTable: vi.fn(),
showTopUpCreditsDialog: vi.fn(),
trackAddApiCreditButtonClicked: vi.fn(),
checkForCompletedTopup: vi.fn(),
getMyEvents: vi.fn().mockResolvedValue({ events: [] }),
customerEventsError: null as string | null,
toastErrorHandler: vi.fn()
}))

Expand Down Expand Up @@ -91,7 +94,15 @@ vi.mock('@/services/dialogService', () => ({

vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked
trackAddApiCreditButtonClicked: state.trackAddApiCreditButtonClicked,
checkForCompletedTopup: state.checkForCompletedTopup
})
}))

vi.mock('@/services/customerEventsService', () => ({
useCustomerEventsService: () => ({
getMyEvents: state.getMyEvents,
error: computed(() => state.customerEventsError)
})
}))

Expand Down Expand Up @@ -173,6 +184,14 @@ function activeProSubscription() {
}
}

function createDeferred() {
let resolve: () => void = () => {}
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}

describe('CreditsTile', () => {
beforeEach(() => {
state.balance = null
Expand All @@ -185,9 +204,11 @@ describe('CreditsTile', () => {
state.isLoading = false
state.canTopUp = true
state.type = 'workspace'
state.customerEventsError = null
mockIsCloud.value = true
vi.clearAllMocks()
vi.unstubAllEnvs()
localStorage.clear()
})

it('renders the total balance (cents converted to credits) with the remaining suffix', () => {
Expand Down Expand Up @@ -443,6 +464,136 @@ describe('CreditsTile', () => {
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
})

it('keeps refreshing on focus until a pending top-up is confirmed', async () => {
activeProSubscription()
state.type = 'legacy'
localStorage.setItem('pending_topup_timestamp', Date.now().toString())
renderTile()

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(2))

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(3))
expect(state.fetchStatus).toHaveBeenCalledTimes(3)
expect(localStorage.getItem('pending_topup_timestamp')).not.toBeNull()
})

it('runs a trailing refresh when focus returns during an active refresh', async () => {
activeProSubscription()
state.type = 'legacy'
renderTile()
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
await new Promise((resolve) => setTimeout(resolve, 0))
vi.clearAllMocks()

const balanceRefresh = createDeferred()
const statusRefresh = createDeferred()
state.fetchBalance
.mockImplementationOnce(() => balanceRefresh.promise)
.mockResolvedValue(undefined)
state.fetchStatus
.mockImplementationOnce(() => statusRefresh.promise)
.mockResolvedValue(undefined)
localStorage.setItem('pending_topup_timestamp', Date.now().toString())

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
window.dispatchEvent(new Event('focus'))
expect(state.fetchBalance).toHaveBeenCalledOnce()

balanceRefresh.resolve()
statusRefresh.resolve()

await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(2))
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
expect(state.getMyEvents).toHaveBeenCalledTimes(2)
})

it('waits for a failed refresh to settle before its trailing refresh', async () => {
activeProSubscription()
state.type = 'legacy'
renderTile()
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
await new Promise((resolve) => setTimeout(resolve, 0))
vi.clearAllMocks()

const statusRefresh = createDeferred()
state.fetchBalance
.mockRejectedValueOnce(new Error('balance unavailable'))
.mockResolvedValue(undefined)
state.fetchStatus
.mockImplementationOnce(() => statusRefresh.promise)
.mockResolvedValue(undefined)
localStorage.setItem('pending_topup_timestamp', Date.now().toString())

window.dispatchEvent(new Event('focus'))
await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledOnce())
window.dispatchEvent(new Event('focus'))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(state.fetchBalance).toHaveBeenCalledOnce()

statusRefresh.resolve()

await waitFor(() => expect(state.fetchBalance).toHaveBeenCalledTimes(2))
expect(state.fetchStatus).toHaveBeenCalledTimes(2)
expect(state.getMyEvents).toHaveBeenCalledOnce()
})

it('clears a confirmed legacy top-up before the next focus', async () => {
activeProSubscription()
state.type = 'legacy'
localStorage.setItem('pending_topup_timestamp', Date.now().toString())
const events = [{ event_type: 'credit_added' }]
state.getMyEvents.mockResolvedValueOnce({ events })
state.checkForCompletedTopup.mockImplementationOnce(() => {
localStorage.removeItem('pending_topup_timestamp')
return true
})

renderTile()

await waitFor(() =>
expect(localStorage.getItem('pending_topup_timestamp')).toBeNull()
)
expect(state.checkForCompletedTopup).toHaveBeenCalledWith(events)
vi.clearAllMocks()

window.dispatchEvent(new Event('focus'))

await waitFor(() => expect(state.fetchBalance).not.toHaveBeenCalled())
expect(state.getMyEvents).not.toHaveBeenCalled()
})

it('retries legacy completion reconciliation after a request failure', async () => {
activeProSubscription()
state.type = 'legacy'
localStorage.setItem('pending_topup_timestamp', Date.now().toString())
state.customerEventsError = 'events unavailable'
state.getMyEvents
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ events: [{ event_type: 'credit_added' }] })
state.checkForCompletedTopup.mockImplementationOnce(() => {
localStorage.removeItem('pending_topup_timestamp')
return true
})

renderTile()
await waitFor(() =>
expect(state.toastErrorHandler).toHaveBeenCalledWith(
new Error('events unavailable')
)
)
expect(localStorage.getItem('pending_topup_timestamp')).not.toBeNull()

window.dispatchEvent(new Event('focus'))

await waitFor(() =>
expect(localStorage.getItem('pending_topup_timestamp')).toBeNull()
)
expect(state.getMyEvents).toHaveBeenCalledTimes(2)
})

it('surfaces a failure toast when a refresh rejects', async () => {
activeProSubscription()
const failure = new Error('network down')
Expand Down
58 changes: 53 additions & 5 deletions src/platform/cloud/subscription/components/CreditsTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ import {
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { pendingTopupNeedsRefresh } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useCustomerEventsService } from '@/services/customerEventsService'
import { useDialogService } from '@/services/dialogService'

const { zeroState = false, inactivePlan } = defineProps<{
Expand Down Expand Up @@ -269,6 +270,7 @@ const {
const { permissions } = useWorkspaceUI()
const { showPricingTable } = useSubscriptionDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const customerEventsService = useCustomerEventsService()
const dialogService = useDialogService()
const telemetry = useTelemetry()

Expand Down Expand Up @@ -408,9 +410,55 @@ const emptyStateNotice = computed(() => {
return null
})

const handleRefresh = wrapWithErrorHandlingAsync(async () => {
await Promise.all([fetchBalance(), fetchStatus()])
})
async function refreshCredits() {
const results = await Promise.allSettled([fetchBalance(), fetchStatus()])
for (const result of results) {
if (result.status === 'rejected') throw result.reason
}

if (!pendingTopupNeedsRefresh()) return

const response = await customerEventsService.getMyEvents({
page: 1,
limit: 10
})
if (!response) {
throw new Error(
customerEventsService.error.value ?? 'Fetching customer events failed'
)
}
telemetry?.checkForCompletedTopup(response.events)
}

let refreshRequested = false
let activeRefresh: Promise<void> | null = null

async function refreshLatestCredits() {
refreshRequested = true
if (activeRefresh) return activeRefresh

activeRefresh = (async () => {
let lastError: unknown
while (refreshRequested) {
refreshRequested = false
try {
await refreshCredits()
lastError = undefined
} catch (error) {
lastError = error
}
}
if (lastError) throw lastError
})()

try {
await activeRefresh
} finally {
activeRefresh = null
}
}

const handleRefresh = wrapWithErrorHandlingAsync(refreshLatestCredits)

function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
Expand All @@ -422,7 +470,7 @@ function handleUpgradeToAddCredits() {
}

async function handleWindowFocus() {
if (consumePendingTopup()) {
if (pendingTopupNeedsRefresh()) {
await handleRefresh()
}
}
Expand Down
Loading
Loading