mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 15:10:17 +00:00
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:
235
src/platform/cloud/subscription/components/PricingTable.test.ts
Normal file
235
src/platform/cloud/subscription/components/PricingTable.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user