chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View File

@@ -0,0 +1,235 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
import Button from '@/components/ui/button/Button.vue'
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal,
reportError: mockReportError
})
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: vi.fn(
(fn, errorHandler) =>
async (...args: unknown[]) => {
try {
return await fn(...args)
} catch (error) {
if (errorHandler) {
errorHandler(error)
}
throw error
}
}
)
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getAuthHeader: mockGetAuthHeader
}),
FirebaseAuthStoreError: class extends Error {}
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
global.fetch = vi.fn()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
yearly: 'Yearly',
monthly: 'Monthly',
mostPopular: 'Most Popular',
usdPerMonth: '/ month',
billedYearly: 'Billed yearly ({total})',
billedMonthly: 'Billed monthly',
currentPlan: 'Current Plan',
subscribeTo: 'Subscribe to {plan}',
changeTo: 'Change to {plan}',
maxDuration: {
standard: '30 min',
creator: '30 min',
pro: '1 hr'
},
tiers: {
standard: { name: 'Standard' },
creator: { name: 'Creator' },
pro: { name: 'Pro' }
},
benefits: {
monthlyCredits: '{credits} monthly credits',
maxDuration: '{duration} max duration',
gpu: 'RTX 6000 Pro GPU',
addCredits: 'Add more credits anytime',
customLoRAs: 'Import custom LoRAs'
}
}
}
}
})
function createWrapper() {
return mount(PricingTable, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
components: {
Button
},
stubs: {
SelectButton: {
template: '<div><slot /></div>',
props: ['modelValue', 'options'],
emits: ['update:modelValue']
},
Popover: { template: '<div><slot /></div>' }
}
}
})
}
describe('PricingTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
} as Response)
})
describe('billing portal deep linking', () => {
it('should call accessBillingPortal with yearly tier suffix when billing cycle is yearly (default)', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
const wrapper = createWrapper()
await flushPromises()
const creatorButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Creator'))
expect(creatorButton).toBeDefined()
await creatorButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
})
it('should call accessBillingPortal with different tiers correctly', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
const wrapper = createWrapper()
await flushPromises()
const proButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Pro'))
await proButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
})
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
const wrapper = createWrapper()
await flushPromises()
const currentPlanButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Current Plan'))
await currentPlanButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})
it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const wrapper = createWrapper()
await flushPromises()
const subscribeButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Subscribe'))
await subscribeButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout/'),
expect.any(Object)
)
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://checkout.stripe.com/test',
'_blank'
)
windowOpenSpy.mockRestore()
})
it('should pass correct tier for each subscription level', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'PRO'
const wrapper = createWrapper()
await flushPromises()
const standardButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Standard'))
await standardButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('standard-yearly')
})
})
})

View File

@@ -0,0 +1,321 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
// Mock state refs that can be modified between tests
const mockIsActiveSubscription = ref(false)
const mockIsCancelled = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>('CREATOR')
const mockIsYearlySubscription = ref(false)
const TIER_TO_NAME: Record<string, string> = {
STANDARD: 'Standard',
CREATOR: 'Creator',
PRO: 'Pro',
FOUNDERS_EDITION: "Founder's Edition"
}
// Mock composables - using computed to match composable return types
const mockSubscriptionData = {
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isCancelled: computed(() => mockIsCancelled.value),
formattedRenewalDate: computed(() => '2024-12-31'),
formattedEndDate: computed(() => '2024-12-31'),
subscriptionTier: computed(() => mockSubscriptionTier.value),
subscriptionTierName: computed(() => {
if (!mockSubscriptionTier.value) return ''
const baseName = TIER_TO_NAME[mockSubscriptionTier.value]
return mockIsYearlySubscription.value ? `${baseName} Yearly` : baseName
}),
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
handleInvoiceHistory: vi.fn()
}
const mockCreditsData = {
totalCredits: '10.00 Credits',
monthlyBonusCredits: '5.00 Credits',
prepaidCredits: '5.00 Credits',
isLoadingBalance: false
}
const mockActionsData = {
isLoadingSupport: false,
handleAddApiCredits: vi.fn(),
handleMessageSupport: vi.fn(),
handleRefresh: vi.fn(),
handleLearnMoreClick: vi.fn()
}
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => mockSubscriptionData
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionCredits',
() => ({
useSubscriptionCredits: () => mockCreditsData
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => mockActionsData
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn()
})
})
)
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
authActions: vi.fn(() => ({
accessBillingPortal: vi.fn()
}))
}))
}))
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
title: 'Subscription',
titleUnsubscribed: 'Subscribe',
perMonth: '/ month',
subscribeNow: 'Subscribe Now',
manageSubscription: 'Manage Subscription',
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
creditsRemainingThisMonth: 'Credits remaining this month',
creditsYouveAdded: "Credits you've added",
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
monthlyCreditsRollover: 'Monthly credits rollover info',
prepaidCreditsInfo: 'Prepaid credits info',
viewUsageHistory: 'View Usage History',
addCredits: 'Add Credits',
yourPlanIncludes: 'Your plan includes',
viewMoreDetailsPlans: 'View more details about plans & pricing',
learnMore: 'Learn More',
messageSupport: 'Message Support',
invoiceHistory: 'Invoice History',
partnerNodesCredits: 'Partner nodes pricing',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
tiers: {
founder: {
name: "Founder's Edition",
price: '20.00',
benefits: {
monthlyCredits: '5,460',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
standard: {
name: 'Standard',
price: '20.00',
benefits: {
monthlyCredits: '4,200',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
creator: {
name: 'Creator',
price: '35.00',
benefits: {
monthlyCredits: '7,400',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '30 min',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
},
pro: {
name: 'Pro',
price: '100.00',
benefits: {
monthlyCredits: '21,100',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '1 hr',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
}
}
}
}
}
})
function createWrapper(overrides = {}) {
return mount(SubscriptionPanel, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
CloudBadge: true,
SubscribeButton: true,
SubscriptionBenefits: true,
Button: {
template:
'<button @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon"><slot/></button>',
props: ['variant', 'size'],
emits: ['click']
},
Skeleton: {
template: '<div class="skeleton"></div>'
}
}
},
...overrides
})
}
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock state
mockIsActiveSubscription.value = false
mockIsCancelled.value = false
mockSubscriptionTier.value = 'CREATOR'
mockIsYearlySubscription.value = false
})
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockIsActiveSubscription.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Manage Subscription')
expect(wrapper.text()).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
mockIsActiveSubscription.value = false
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
)
expect(wrapper.text()).not.toContain('Manage Subscription')
expect(wrapper.text()).not.toContain('Add Credits')
})
it('shows renewal date for active non-cancelled subscription', () => {
mockIsActiveSubscription.value = true
mockIsCancelled.value = false
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Renews 2024-12-31')
})
it('shows expiry date for cancelled subscription', () => {
mockIsActiveSubscription.value = true
mockIsCancelled.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')
})
it('displays FOUNDERS_EDITION tier correctly', () => {
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
const wrapper = createWrapper()
expect(wrapper.text()).toContain("Founder's Edition")
expect(wrapper.text()).toContain('5,460')
})
it('displays CREATOR tier correctly', () => {
mockSubscriptionTier.value = 'CREATOR'
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Creator')
expect(wrapper.text()).toContain('7,400')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('10.00 Credits')
expect(wrapper.text()).toContain('5.00 Credits')
})
it('shows loading skeleton when fetching balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBeGreaterThan(0)
})
it('hides skeleton when balance loaded', () => {
mockCreditsData.isLoadingBalance = false
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBe(0)
})
})
// TODO: Re-enable when migrating to VTL so we can find by user visible content.
describe.skip('action buttons', () => {
it('should call handleLearnMoreClick when learn more is clicked', async () => {
const wrapper = createWrapper()
const learnMoreButton = wrapper.find('[data-testid="Learn More"]')
await learnMoreButton.trigger('click')
expect(mockActionsData.handleLearnMoreClick).toHaveBeenCalledOnce()
})
it('should call handleMessageSupport when message support is clicked', async () => {
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
await supportButton.trigger('click')
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
})
it('should call handleRefresh when refresh button is clicked', async () => {
const wrapper = createWrapper()
// Find the refresh button by icon
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
await refreshButton.trigger('click')
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
})
})
describe.skip('loading states', () => {
it('should show loading state on support button when loading', () => {
mockActionsData.isLoadingSupport = true
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
expect(supportButton.attributes('disabled')).toBeDefined()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
expect(refreshButton.attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -0,0 +1,441 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
// Create mocks
const mockIsLoggedIn = ref(false)
const mockReportError = vi.fn()
const mockAccessBillingPortal = vi.fn()
const mockShowSubscriptionRequiredDialog = vi.fn()
const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
const mockTelemetry = {
trackSubscription: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
}
// Mock dependencies
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
isLoggedIn: mockIsLoggedIn
}))
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
reportError: mockReportError,
accessBillingPortal: mockAccessBillingPortal
}))
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: vi.fn(() => ({
wrapWithErrorHandlingAsync: vi.fn(
(fn, errorHandler) =>
async (...args: any[]) => {
try {
return await fn(...args)
} catch (error) {
if (errorHandler) {
errorHandler(error)
}
throw error
}
}
)
}))
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
}))
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader
})),
FirebaseAuthStoreError: class extends Error {}
}))
// Mock fetch
global.fetch = vi.fn()
describe('useSubscription', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsLoggedIn.value = false
mockTelemetry.trackSubscription.mockReset()
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
window.__CONFIG__ = {
subscription_required: true
} as typeof window.__CONFIG__
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response)
})
describe('computed properties', () => {
it('should compute isActiveSubscription correctly when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
const { isActiveSubscription, fetchStatus } = useSubscription()
await fetchStatus()
expect(isActiveSubscription.value).toBe(true)
})
it('should compute isActiveSubscription as false when subscription is inactive', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
mockIsLoggedIn.value = true
const { isActiveSubscription, fetchStatus } = useSubscription()
await fetchStatus()
expect(isActiveSubscription.value).toBe(false)
})
it('should format renewal date correctly', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16T12:00:00Z'
})
} as Response)
mockIsLoggedIn.value = true
const { formattedRenewalDate, fetchStatus } = useSubscription()
await fetchStatus()
// The date format may vary based on timezone, so we just check it's a valid date string
expect(formattedRenewalDate.value).toMatch(/^[A-Za-z]{3} \d{1,2}, \d{4}$/)
expect(formattedRenewalDate.value).toContain('2025')
expect(formattedRenewalDate.value).toContain('Nov')
})
it('should return empty string when renewal date is not available', () => {
const { formattedRenewalDate } = useSubscription()
expect(formattedRenewalDate.value).toBe('')
})
it('should return subscription tier from status', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
renewal_date: '2025-11-16T12:00:00Z'
})
} as Response)
mockIsLoggedIn.value = true
const { subscriptionTier, fetchStatus } = useSubscription()
await fetchStatus()
expect(subscriptionTier.value).toBe('CREATOR')
})
it('should return null when subscription tier is not available', () => {
const { subscriptionTier } = useSubscription()
expect(subscriptionTier.value).toBeNull()
})
})
describe('fetchStatus', () => {
it('should fetch subscription status successfully', async () => {
const mockStatus = {
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
}
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => mockStatus
} as Response)
mockIsLoggedIn.value = true
const { fetchStatus } = useSubscription()
await fetchStatus()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-status'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
})
})
)
})
it('should handle fetch errors gracefully', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
json: async () => ({ message: 'Subscription not found' })
} as Response)
const { fetchStatus } = useSubscription()
await expect(fetchStatus()).rejects.toThrow()
})
})
describe('subscribe', () => {
it('should initiate subscription checkout successfully', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
// Mock window.open
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const { subscribe } = useSubscription()
await subscribe()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
})
})
)
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
windowOpenSpy.mockRestore()
})
it('should throw error when checkout URL is not returned', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({})
} as Response)
const { subscribe } = useSubscription()
await expect(subscribe()).rejects.toThrow()
})
})
describe('requireActiveSubscription', () => {
it('should not show dialog when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
const { requireActiveSubscription } = useSubscription()
await requireActiveSubscription()
expect(mockShowSubscriptionRequiredDialog).not.toHaveBeenCalled()
})
it('should show dialog when subscription is inactive', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: false,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
})
} as Response)
const { requireActiveSubscription } = useSubscription()
await requireActiveSubscription()
expect(mockShowSubscriptionRequiredDialog).toHaveBeenCalled()
})
})
describe('action handlers', () => {
it('should open usage history URL', () => {
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const { handleViewUsageHistory } = useSubscription()
handleViewUsageHistory()
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://stagingplatform.comfy.org/profile/usage',
'_blank'
)
windowOpenSpy.mockRestore()
})
it('should open learn more URL', () => {
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const { handleLearnMore } = useSubscription()
handleLearnMore()
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://docs.comfy.org',
'_blank'
)
windowOpenSpy.mockRestore()
})
it('should call accessBillingPortal for invoice history', async () => {
const { handleInvoiceHistory } = useSubscription()
await handleInvoiceHistory()
expect(mockAccessBillingPortal).toHaveBeenCalled()
})
it('should call accessBillingPortal for manage subscription', async () => {
const { manageSubscription } = useSubscription()
await manageSubscription()
expect(mockAccessBillingPortal).toHaveBeenCalled()
})
it('tracks cancellation after manage subscription when status flips', async () => {
vi.useFakeTimers()
mockIsLoggedIn.value = true
const activeResponse = {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_active',
renewal_date: '2025-11-16'
})
}
const cancelledResponse = {
ok: true,
json: async () => ({
is_active: false,
subscription_id: 'sub_cancelled',
renewal_date: '2025-11-16',
end_date: '2025-12-01'
})
}
vi.mocked(global.fetch)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(cancelledResponse as Response)
try {
const { fetchStatus, manageSubscription } = useSubscription()
await fetchStatus()
await manageSubscription()
await vi.advanceTimersByTimeAsync(5000)
expect(
mockTelemetry.trackMonthlySubscriptionCancelled
).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('handles rapid focus events during cancellation polling', async () => {
vi.useFakeTimers()
mockIsLoggedIn.value = true
const activeResponse = {
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_active',
renewal_date: '2025-11-16'
})
}
const cancelledResponse = {
ok: true,
json: async () => ({
is_active: false,
subscription_id: 'sub_cancelled',
renewal_date: '2025-11-16',
end_date: '2025-12-01'
})
}
vi.mocked(global.fetch)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(activeResponse as Response)
.mockResolvedValueOnce(cancelledResponse as Response)
try {
const { fetchStatus, manageSubscription } = useSubscription()
await fetchStatus()
await manageSubscription()
window.dispatchEvent(new Event('focus'))
await vi.waitFor(() => {
expect(
mockTelemetry.trackMonthlySubscriptionCancelled
).toHaveBeenCalledTimes(1)
})
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute
})
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
writable: true,
value: mockOpen
})
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('handleAddApiCredits', () => {
it('should call showTopUpCreditsDialog', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
})
})
describe('handleMessageSupport', () => {
it('should execute support command and manage loading state', async () => {
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
expect(isLoadingSupport.value).toBe(false)
const promise = handleMessageSupport()
expect(isLoadingSupport.value).toBe(true)
await promise
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(isLoadingSupport.value).toBe(false)
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
await handleMessageSupport()
expect(isLoadingSupport.value).toBe(false)
})
})
describe('handleRefresh', () => {
it('should call both fetchBalance and fetchStatus', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
})
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
})
})
describe('handleLearnMoreClick', () => {
it('should open learn more URL', () => {
const { handleLearnMoreClick } = useSubscriptionActions()
handleLearnMoreClick()
expect(mockOpen).toHaveBeenCalledWith(
'https://docs.comfy.org/get_started/cloud',
'_blank'
)
})
})
})

View File

@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, effectScope, ref } from 'vue'
import type { EffectScope } from 'vue'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher'
import type { TelemetryProvider } from '@/platform/telemetry/types'
describe('useSubscriptionCancellationWatcher', () => {
const trackMonthlySubscriptionCancelled = vi.fn()
const telemetryMock: Pick<
TelemetryProvider,
'trackMonthlySubscriptionCancelled'
> = {
trackMonthlySubscriptionCancelled
}
const baseStatus: CloudSubscriptionStatusResponse = {
is_active: true,
subscription_id: 'sub_123',
renewal_date: '2025-11-16'
}
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(
baseStatus
)
const isActive = ref(true)
const isActiveSubscription = computed(() => isActive.value)
let shouldWatch = true
const shouldWatchCancellation = () => shouldWatch
const activeScopes: EffectScope[] = []
const initWatcher = (
options: Parameters<typeof useSubscriptionCancellationWatcher>[0]
): ReturnType<typeof useSubscriptionCancellationWatcher> => {
const scope = effectScope()
let result: ReturnType<typeof useSubscriptionCancellationWatcher> | null =
null
scope.run(() => {
result = useSubscriptionCancellationWatcher(options)
})
if (!result) {
throw new Error('Failed to initialize cancellation watcher')
}
activeScopes.push(scope)
return result
}
beforeEach(() => {
vi.useFakeTimers()
trackMonthlySubscriptionCancelled.mockReset()
subscriptionStatus.value = { ...baseStatus }
isActive.value = true
shouldWatch = true
})
afterEach(() => {
activeScopes.forEach((scope) => scope.stop())
activeScopes.length = 0
vi.useRealTimers()
})
it('polls with exponential backoff and fires telemetry once cancellation detected', async () => {
const fetchStatus = vi.fn(async () => {
if (fetchStatus.mock.calls.length === 2) {
isActive.value = false
subscriptionStatus.value = {
is_active: false,
subscription_id: 'sub_cancelled',
renewal_date: '2025-11-16',
end_date: '2025-12-01'
}
}
})
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation
})
startCancellationWatcher()
await vi.advanceTimersByTimeAsync(5000)
expect(fetchStatus).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(15000)
expect(fetchStatus).toHaveBeenCalledTimes(2)
expect(
telemetryMock.trackMonthlySubscriptionCancelled
).toHaveBeenCalledTimes(1)
})
it('triggers a check immediately when window regains focus', async () => {
const fetchStatus = vi.fn(async () => {
isActive.value = false
subscriptionStatus.value = {
...baseStatus,
is_active: false,
end_date: '2025-12-01'
}
})
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation
})
startCancellationWatcher()
window.dispatchEvent(new Event('focus'))
await Promise.resolve()
expect(fetchStatus).toHaveBeenCalledTimes(1)
expect(
telemetryMock.trackMonthlySubscriptionCancelled
).toHaveBeenCalledTimes(1)
})
it('stops after max attempts when subscription stays active', async () => {
const fetchStatus = vi.fn(async () => {})
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation
})
startCancellationWatcher()
const delays = [5000, 15000, 45000, 135000]
for (const delay of delays) {
await vi.advanceTimersByTimeAsync(delay)
}
expect(fetchStatus).toHaveBeenCalledTimes(4)
expect(trackMonthlySubscriptionCancelled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(200000)
expect(fetchStatus).toHaveBeenCalledTimes(4)
})
it('does not start watcher when guard fails or subscription inactive', async () => {
const fetchStatus = vi.fn()
const { startCancellationWatcher } = initWatcher({
fetchStatus,
isActiveSubscription,
subscriptionStatus,
telemetry: telemetryMock,
shouldWatchCancellation
})
shouldWatch = false
startCancellationWatcher()
await vi.advanceTimersByTimeAsync(60000)
expect(fetchStatus).not.toHaveBeenCalled()
shouldWatch = true
isActive.value = false
subscriptionStatus.value = {
...baseStatus,
is_active: false
}
startCancellationWatcher()
await vi.advanceTimersByTimeAsync(60000)
expect(fetchStatus).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,161 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueI18nModule from 'vue-i18n'
import * as comfyCredits from '@/base/credits/comfyCredits'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
vi.mock(
'vue-i18n',
async (importOriginal: () => Promise<typeof VueI18nModule>) => {
const actual = await importOriginal()
return {
...actual,
useI18n: () => ({
t: () => 'Credits',
locale: { value: 'en-US' }
})
}
}
)
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn(() => ({
onAuthStateChanged: vi.fn(),
setPersistence: vi.fn()
}))
}))
vi.mock('firebase/auth', () => ({
onAuthStateChanged: vi.fn(() => {
// Mock the callback to be called immediately for testing
return vi.fn()
}),
onIdTokenChanged: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined),
browserLocalPersistence: {},
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
}
}))
// Mock other dependencies
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
track: vi.fn()
})
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({
headers: {}
})
}))
describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore>
beforeEach(() => {
setActivePinia(createPinia())
authStore = useFirebaseAuthStore()
vi.clearAllMocks()
})
describe('totalCredits', () => {
it('should return "0" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0')
})
it('should return "0" when amount_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('211')
})
it('should handle formatting errors by throwing', async () => {
const formatSpy = vi.spyOn(comfyCredits, 'formatCreditsFromCents')
formatSpy.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(() => totalCredits.value).toThrow('Formatting error')
formatSpy.mockRestore()
})
})
describe('monthlyBonusCredits', () => {
it('should return "0" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0')
})
it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = {
cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('422')
})
})
describe('prepaidCredits', () => {
it('should return "0" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0')
})
it('should format prepaid_balance_micros correctly', () => {
authStore.balance = {
prepaid_balance_micros: 300
} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('633')
})
})
describe('isLoadingBalance', () => {
it('should reflect authStore.isFetchingBalance', () => {
authStore.isFetchingBalance = true
const { isLoadingBalance } = useSubscriptionCredits()
expect(isLoadingBalance.value).toBe(true)
authStore.isFetchingBalance = false
expect(isLoadingBalance.value).toBe(false)
})
})
})