mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-26 07:57:36 +00:00
Compare commits
11 Commits
cloud/v1.4
...
glary/rena
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c266aa8d6 | ||
|
|
6133314930 | ||
|
|
18ef64ba19 | ||
|
|
7d8b21cb10 | ||
|
|
572aac3e39 | ||
|
|
0a06ccae75 | ||
|
|
1b7e873fe2 | ||
|
|
b7f9c3e3f4 | ||
|
|
5993297f1a | ||
|
|
c8cee3390a | ||
|
|
df04be1759 |
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = ref(true)
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({
|
||||
default: { template: '<div data-testid="queue-button">Queue</div>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
|
||||
default: { template: '<div data-testid="subscribe-button">Subscribe</div>' }
|
||||
}))
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
})
|
||||
|
||||
it('shows ComfyQueueButton when canAccessSubscriptionFeatures is true', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
render(CloudRunButtonWrapper)
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('subscribe-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows SubscribeToRunButton when canAccessSubscriptionFeatures is false', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
render(CloudRunButtonWrapper)
|
||||
|
||||
expect(screen.getByTestId('subscribe-button')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
:key="canAccessSubscriptionFeatures ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
canAccessSubscriptionFeatures.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
v-else-if="canAccessSubscriptionFeatures"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
>
|
||||
@@ -137,7 +137,7 @@ const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
|
||||
@@ -101,12 +101,13 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useSubscription composable
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
// Mock the useSubscription composable with hoisted refs for per-test manipulation
|
||||
const mockCanAccessSubscriptionFeatures = ref(true)
|
||||
const mockIsFreeTier = ref(false)
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: ref(true),
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscriptionTierName: ref('Creator'),
|
||||
subscriptionTier: ref('CREATOR'),
|
||||
@@ -190,6 +191,7 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
@@ -438,6 +440,44 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('canAccessSubscriptionFeatures is false', () => {
|
||||
beforeEach(() => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
})
|
||||
|
||||
it('hides credits section when canAccessSubscriptionFeatures is false', () => {
|
||||
renderComponent()
|
||||
// Credits help button is inside the credits section
|
||||
expect(
|
||||
screen.queryByTestId('credits-info-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides partner nodes menu item when canAccessSubscriptionFeatures is false', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('partner-nodes-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides manage plan menu item when canAccessSubscriptionFeatures is false', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('manage-plan-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows logout menu item when canAccessSubscriptionFeatures is false', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('logout-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows user settings menu item when canAccessSubscriptionFeatures is false', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('user-settings-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-cloud distribution', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<div
|
||||
v-if="canAccessSubscriptionFeatures"
|
||||
class="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
@@ -85,7 +88,7 @@
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
v-if="canAccessSubscriptionFeatures"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
@@ -115,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
v-if="canAccessSubscriptionFeatures"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="manage-plan-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
@@ -186,7 +189,7 @@ const authStore = useAuthStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isFreeTier,
|
||||
subscriptionTierName,
|
||||
subscriptionTier,
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
|
||||
|
||||
const mockAuthStore = vi.hoisted(() => ({
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
initiateCreditPurchase: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ checkout_url: 'https://checkout.example.com' })
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
@@ -35,8 +38,12 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const mockStartTopupTracking = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => undefined)
|
||||
useTelemetry: vi.fn(() => ({
|
||||
startTopupTracking: mockStartTopupTracking
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
@@ -59,9 +66,13 @@ vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => mockAuthStore)
|
||||
}))
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = vi.hoisted(() => ({
|
||||
value: false
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
@@ -193,3 +204,40 @@ describe('useAuthActions.logout', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions.purchaseCredits', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
})
|
||||
|
||||
it('returns early when canAccessSubscriptionFeatures is false', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
await purchaseCredits(10)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('initiates credit purchase when canAccessSubscriptionFeatures is true', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
const mockOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
await purchaseCredits(10)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
|
||||
amount_micros: 10_000_000,
|
||||
currency: 'usd'
|
||||
})
|
||||
expect(mockStartTopupTracking).toHaveBeenCalled()
|
||||
expect(mockOpen).toHaveBeenCalledWith(
|
||||
'https://checkout.example.com',
|
||||
'_blank'
|
||||
)
|
||||
|
||||
mockOpen.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,8 +112,8 @@ export const useAuthActions = () => {
|
||||
)
|
||||
|
||||
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
if (!isActiveSubscription.value) return
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
if (!canAccessSubscriptionFeatures.value) return
|
||||
|
||||
const response = await authStore.initiateCreditPurchase({
|
||||
amount_micros: usdToMicros(amount),
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface BillingState {
|
||||
* Convenience computed for checking if subscription is active.
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
canAccessSubscriptionFeatures: ComputedRef<boolean>
|
||||
/**
|
||||
* Whether the current billing context has a FREE tier subscription.
|
||||
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
|
||||
|
||||
@@ -47,7 +47,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
canAccessSubscriptionFeatures: { value: true },
|
||||
subscriptionTier: { value: 'PRO' },
|
||||
subscriptionDuration: { value: 'MONTHLY' },
|
||||
formattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
@@ -173,9 +173,9 @@ describe('useBillingContext', () => {
|
||||
await expect(manageSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
it('provides canAccessSubscriptionFeatures convenience computed', () => {
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
expect(canAccessSubscriptionFeatures.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes requireActiveSubscription action', async () => {
|
||||
|
||||
@@ -116,8 +116,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.currentPlanSlug)
|
||||
)
|
||||
|
||||
const isActiveSubscription = computed(() =>
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
const canAccessSubscriptionFeatures = computed(() =>
|
||||
toValue(activeContext.value.canAccessSubscriptionFeatures)
|
||||
)
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
@@ -239,7 +239,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isFreeTier,
|
||||
getMaxSeats,
|
||||
|
||||
|
||||
325
src/composables/billing/useLegacyBilling.test.ts
Normal file
325
src/composables/billing/useLegacyBilling.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
|
||||
const {
|
||||
mockCanAccessSubscriptionFeatures,
|
||||
mockSubscriptionTier,
|
||||
mockSubscriptionDuration,
|
||||
mockFormattedRenewalDate,
|
||||
mockFormattedEndDate,
|
||||
mockIsCancelled,
|
||||
mockFetchStatus,
|
||||
mockManageSubscription,
|
||||
mockSubscribe,
|
||||
mockShowSubscriptionDialog,
|
||||
mockBalance,
|
||||
mockFetchBalance
|
||||
} = vi.hoisted(() => ({
|
||||
mockCanAccessSubscriptionFeatures: { value: true },
|
||||
mockSubscriptionTier: { value: 'PRO' as string | null },
|
||||
mockSubscriptionDuration: { value: 'MONTHLY' as string | null },
|
||||
mockFormattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
mockFormattedEndDate: { value: '' },
|
||||
mockIsCancelled: { value: false },
|
||||
mockFetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
mockManageSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
mockSubscribe: vi.fn().mockResolvedValue(undefined),
|
||||
mockShowSubscriptionDialog: vi.fn(),
|
||||
mockBalance: {
|
||||
value: {
|
||||
amount_micros: 5000000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 5000000,
|
||||
prepaid_balance_micros: 0,
|
||||
cloud_credit_balance_micros: 0
|
||||
} as {
|
||||
amount_micros?: number
|
||||
currency?: string
|
||||
effective_balance_micros?: number
|
||||
prepaid_balance_micros?: number
|
||||
cloud_credit_balance_micros?: number
|
||||
} | null
|
||||
},
|
||||
mockFetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
subscriptionTier: mockSubscriptionTier,
|
||||
subscriptionDuration: mockSubscriptionDuration,
|
||||
formattedRenewalDate: mockFormattedRenewalDate,
|
||||
formattedEndDate: mockFormattedEndDate,
|
||||
isCancelled: mockIsCancelled,
|
||||
fetchStatus: mockFetchStatus,
|
||||
manageSubscription: mockManageSubscription,
|
||||
subscribe: mockSubscribe,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
get balance() {
|
||||
return mockBalance.value
|
||||
},
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useLegacyBilling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'PRO'
|
||||
mockSubscriptionDuration.value = 'MONTHLY'
|
||||
mockFormattedRenewalDate.value = 'Jan 1, 2025'
|
||||
mockFormattedEndDate.value = ''
|
||||
mockIsCancelled.value = false
|
||||
mockBalance.value = {
|
||||
amount_micros: 5000000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 5000000,
|
||||
prepaid_balance_micros: 0,
|
||||
cloud_credit_balance_micros: 0
|
||||
}
|
||||
})
|
||||
|
||||
describe('canAccessSubscriptionFeatures', () => {
|
||||
it('returns true when subscription can access features', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
const { canAccessSubscriptionFeatures } = useLegacyBilling()
|
||||
expect(canAccessSubscriptionFeatures.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when subscription cannot access features', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
const { canAccessSubscriptionFeatures } = useLegacyBilling()
|
||||
expect(canAccessSubscriptionFeatures.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFreeTier', () => {
|
||||
it('returns true when subscription tier is FREE', () => {
|
||||
mockSubscriptionTier.value = 'FREE'
|
||||
const { isFreeTier } = useLegacyBilling()
|
||||
expect(isFreeTier.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when subscription tier is not FREE', () => {
|
||||
mockSubscriptionTier.value = 'PRO'
|
||||
const { isFreeTier } = useLegacyBilling()
|
||||
expect(isFreeTier.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription', () => {
|
||||
it('returns subscription info when active', () => {
|
||||
const { subscription } = useLegacyBilling()
|
||||
expect(subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: 'Jan 1, 2025',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no subscription and no tier', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
mockSubscriptionTier.value = null
|
||||
const { subscription } = useLegacyBilling()
|
||||
expect(subscription.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns subscription with endDate when cancelled', () => {
|
||||
mockIsCancelled.value = true
|
||||
mockFormattedEndDate.value = 'Feb 1, 2025'
|
||||
const { subscription } = useLegacyBilling()
|
||||
expect(subscription.value?.isCancelled).toBe(true)
|
||||
expect(subscription.value?.endDate).toBe('Feb 1, 2025')
|
||||
})
|
||||
|
||||
it('returns hasFunds false when balance is zero', () => {
|
||||
mockBalance.value = { amount_micros: 0 }
|
||||
const { subscription } = useLegacyBilling()
|
||||
expect(subscription.value?.hasFunds).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('balance', () => {
|
||||
it('returns balance info from auth store', () => {
|
||||
const { balance } = useLegacyBilling()
|
||||
expect(balance.value).toEqual({
|
||||
amountMicros: 5000000,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 5000000,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no balance', () => {
|
||||
mockBalance.value = null
|
||||
const { balance } = useLegacyBilling()
|
||||
expect(balance.value).toBeNull()
|
||||
})
|
||||
|
||||
it('handles missing optional balance fields', () => {
|
||||
mockBalance.value = { amount_micros: 1000 }
|
||||
const { balance } = useLegacyBilling()
|
||||
expect(balance.value).toEqual({
|
||||
amountMicros: 1000,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 1000,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('plans', () => {
|
||||
it('returns empty array (legacy has no plans)', () => {
|
||||
const { plans } = useLegacyBilling()
|
||||
expect(plans.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentPlanSlug', () => {
|
||||
it('returns null (legacy has no plan slugs)', () => {
|
||||
const { currentPlanSlug } = useLegacyBilling()
|
||||
expect(currentPlanSlug.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('fetches status and balance', async () => {
|
||||
const { initialize } = useLegacyBilling()
|
||||
await initialize()
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-initialize if already initialized', async () => {
|
||||
const { initialize } = useLegacyBilling()
|
||||
await initialize()
|
||||
await initialize()
|
||||
expect(mockFetchStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-fetches balance for free tier with zero balance', async () => {
|
||||
mockSubscriptionTier.value = 'FREE'
|
||||
mockBalance.value = { amount_micros: 0 }
|
||||
const { initialize } = useLegacyBilling()
|
||||
await initialize()
|
||||
expect(mockFetchBalance).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockFetchStatus.mockRejectedValueOnce(new Error('Network error'))
|
||||
const { initialize, error } = useLegacyBilling()
|
||||
await expect(initialize()).rejects.toThrow('Network error')
|
||||
expect(error.value).toBe('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchStatus', () => {
|
||||
it('calls underlying fetchStatus', async () => {
|
||||
const { fetchStatus } = useLegacyBilling()
|
||||
await fetchStatus()
|
||||
expect(mockFetchStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockFetchStatus.mockRejectedValueOnce(new Error('Fetch failed'))
|
||||
const { fetchStatus, error } = useLegacyBilling()
|
||||
await expect(fetchStatus()).rejects.toThrow('Fetch failed')
|
||||
expect(error.value).toBe('Fetch failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchBalance', () => {
|
||||
it('calls auth store fetchBalance', async () => {
|
||||
const { fetchBalance } = useLegacyBilling()
|
||||
await fetchBalance()
|
||||
expect(mockFetchBalance).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockFetchBalance.mockRejectedValueOnce(new Error('Balance fetch failed'))
|
||||
const { fetchBalance, error } = useLegacyBilling()
|
||||
await expect(fetchBalance()).rejects.toThrow('Balance fetch failed')
|
||||
expect(error.value).toBe('Balance fetch failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('calls legacy subscribe', async () => {
|
||||
const { subscribe } = useLegacyBilling()
|
||||
await subscribe('pro-monthly')
|
||||
expect(mockSubscribe).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewSubscribe', () => {
|
||||
it('returns null (legacy does not support preview)', async () => {
|
||||
const { previewSubscribe } = useLegacyBilling()
|
||||
const result = await previewSubscribe('pro-monthly')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('manageSubscription', () => {
|
||||
it('calls legacy manageSubscription', async () => {
|
||||
const { manageSubscription } = useLegacyBilling()
|
||||
await manageSubscription()
|
||||
expect(mockManageSubscription).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelSubscription', () => {
|
||||
it('calls legacy manageSubscription', async () => {
|
||||
const { cancelSubscription } = useLegacyBilling()
|
||||
await cancelSubscription()
|
||||
expect(mockManageSubscription).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchPlans', () => {
|
||||
it('does nothing (legacy has no plans)', async () => {
|
||||
const { fetchPlans } = useLegacyBilling()
|
||||
await fetchPlans()
|
||||
// No error should be thrown
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireActiveSubscription', () => {
|
||||
it('does not show dialog when subscription can access features', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
const { requireActiveSubscription } = useLegacyBilling()
|
||||
await requireActiveSubscription()
|
||||
expect(mockShowSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows dialog when subscription cannot access features', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
const { requireActiveSubscription } = useLegacyBilling()
|
||||
await requireActiveSubscription()
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showSubscriptionDialog', () => {
|
||||
it('calls legacy showSubscriptionDialog', () => {
|
||||
const { showSubscriptionDialog } = useLegacyBilling()
|
||||
showSubscriptionDialog()
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
*/
|
||||
export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const {
|
||||
isActiveSubscription: legacyIsActiveSubscription,
|
||||
canAccessSubscriptionFeatures: legacyCanAccessSubscriptionFeatures,
|
||||
subscriptionTier,
|
||||
subscriptionDuration,
|
||||
formattedRenewalDate,
|
||||
@@ -39,16 +39,18 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
|
||||
const canAccessSubscriptionFeatures = computed(
|
||||
() => legacyCanAccessSubscriptionFeatures.value
|
||||
)
|
||||
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
|
||||
if (!legacyCanAccessSubscriptionFeatures.value && !subscriptionTier.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
isActive: legacyIsActiveSubscription.value,
|
||||
isActive: legacyCanAccessSubscriptionFeatures.value,
|
||||
tier: subscriptionTier.value,
|
||||
duration: subscriptionDuration.value,
|
||||
planSlug: null, // Legacy doesn't use plan slugs
|
||||
@@ -159,7 +161,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!canAccessSubscriptionFeatures.value) {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
@@ -177,7 +179,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isFreeTier,
|
||||
|
||||
// Actions
|
||||
|
||||
@@ -22,6 +22,8 @@ vi.mock('vue-i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const mockQueuePrompt = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockGraphClear = vi.fn()
|
||||
const mockDs = {
|
||||
@@ -55,7 +57,8 @@ vi.mock('@/scripts/app', () => {
|
||||
canvas: mockCanvas,
|
||||
rootGraph: {
|
||||
clear: mockGraphClear
|
||||
}
|
||||
},
|
||||
queuePrompt: mockQueuePrompt
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -102,9 +105,13 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
|
||||
const mockTrackHelpResourceClicked = vi.hoisted(() => vi.fn())
|
||||
const mockTrackRunButton = vi.hoisted(() => vi.fn())
|
||||
const mockTrackWorkflowExecution = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked,
|
||||
trackRunButton: mockTrackRunButton,
|
||||
trackWorkflowExecution: mockTrackWorkflowExecution
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -125,6 +132,12 @@ vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueSettingsStore', () => ({
|
||||
useQueueSettingsStore: vi.fn(() => ({
|
||||
batchCount: 1
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockChangeTracker = vi.hoisted(() => ({
|
||||
captureCanvasState: vi.fn()
|
||||
}))
|
||||
@@ -161,15 +174,18 @@ vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isActiveSubscription: vi.fn().mockReturnValue(true),
|
||||
canAccessSubscriptionFeatures: vi.fn().mockReturnValue(true),
|
||||
showSubscriptionDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = vi.hoisted(() => ({ value: true }))
|
||||
const mockShowSubscriptionDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: true },
|
||||
showSubscriptionDialog: vi.fn()
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -607,4 +623,68 @@ describe('useCoreCommands', () => {
|
||||
expect(mockShowAbout).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Queue commands with subscription check', () => {
|
||||
const findCmd = (id: string) =>
|
||||
useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockShowSubscriptionDialog.mockClear()
|
||||
mockQueuePrompt.mockClear()
|
||||
mockTrackRunButton.mockClear()
|
||||
mockTrackWorkflowExecution.mockClear()
|
||||
})
|
||||
|
||||
it('Comfy.QueuePrompt shows subscription dialog when canAccessSubscriptionFeatures is false', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
|
||||
await findCmd('Comfy.QueuePrompt').function()
|
||||
|
||||
expect(mockTrackRunButton).toHaveBeenCalled()
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
expect(mockQueuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Comfy.QueuePrompt queues prompt when canAccessSubscriptionFeatures is true', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
|
||||
await findCmd('Comfy.QueuePrompt').function()
|
||||
|
||||
expect(mockTrackRunButton).toHaveBeenCalled()
|
||||
expect(mockShowSubscriptionDialog).not.toHaveBeenCalled()
|
||||
expect(mockTrackWorkflowExecution).toHaveBeenCalled()
|
||||
expect(mockQueuePrompt).toHaveBeenCalledWith(0, 1)
|
||||
})
|
||||
|
||||
it('Comfy.QueuePromptFront shows subscription dialog when canAccessSubscriptionFeatures is false', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
|
||||
await findCmd('Comfy.QueuePromptFront').function()
|
||||
|
||||
expect(mockTrackRunButton).toHaveBeenCalled()
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
expect(mockQueuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Comfy.QueuePromptFront queues prompt at front when canAccessSubscriptionFeatures is true', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
|
||||
await findCmd('Comfy.QueuePromptFront').function()
|
||||
|
||||
expect(mockTrackRunButton).toHaveBeenCalled()
|
||||
expect(mockShowSubscriptionDialog).not.toHaveBeenCalled()
|
||||
expect(mockTrackWorkflowExecution).toHaveBeenCalled()
|
||||
expect(mockQueuePrompt).toHaveBeenCalledWith(-1, 1)
|
||||
})
|
||||
|
||||
it('Comfy.QueueSelectedOutputNodes shows subscription dialog when canAccessSubscriptionFeatures is false', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
|
||||
await findCmd('Comfy.QueueSelectedOutputNodes').function()
|
||||
|
||||
expect(mockTrackRunButton).toHaveBeenCalled()
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,8 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures, showSubscriptionDialog } =
|
||||
useBillingContext()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
@@ -498,7 +499,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!canAccessSubscriptionFeatures.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
@@ -521,7 +522,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!canAccessSubscriptionFeatures.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
@@ -543,7 +544,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!canAccessSubscriptionFeatures.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
103
src/extensions/core/cloudRemoteConfig.test.ts
Normal file
103
src/extensions/core/cloudRemoteConfig.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockIsLoggedIn,
|
||||
mockCanAccessSubscriptionFeatures,
|
||||
mockRefreshRemoteConfig,
|
||||
mockRegisterExtension,
|
||||
mockWatchDebounced
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsLoggedIn: { value: false },
|
||||
mockCanAccessSubscriptionFeatures: { value: true },
|
||||
mockRefreshRemoteConfig: vi.fn(),
|
||||
mockRegisterExtension: vi.fn(),
|
||||
mockWatchDebounced: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
watchDebounced: mockWatchDebounced
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
isLoggedIn: mockIsLoggedIn
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/remoteConfig/refreshRemoteConfig', () => ({
|
||||
refreshRemoteConfig: mockRefreshRemoteConfig
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: mockRegisterExtension
|
||||
})
|
||||
}))
|
||||
|
||||
describe('cloudRemoteConfig extension', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mockIsLoggedIn.value = false
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
})
|
||||
|
||||
it('registers extension with correct name', async () => {
|
||||
await import('./cloudRemoteConfig')
|
||||
|
||||
expect(mockRegisterExtension).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Comfy.Cloud.RemoteConfig'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('setup watches isLoggedIn and canAccessSubscriptionFeatures', async () => {
|
||||
await import('./cloudRemoteConfig')
|
||||
|
||||
const registeredExtension = mockRegisterExtension.mock.calls[0][0]
|
||||
await registeredExtension.setup()
|
||||
|
||||
expect(mockWatchDebounced).toHaveBeenCalledWith(
|
||||
[mockIsLoggedIn, mockCanAccessSubscriptionFeatures],
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ debounce: 256, immediate: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('watch callback refreshes config when logged in', async () => {
|
||||
await import('./cloudRemoteConfig')
|
||||
|
||||
const registeredExtension = mockRegisterExtension.mock.calls[0][0]
|
||||
await registeredExtension.setup()
|
||||
|
||||
// Get the callback passed to watchDebounced
|
||||
const watchCallback = mockWatchDebounced.mock.calls[0][1]
|
||||
|
||||
// Simulate logged in state
|
||||
mockIsLoggedIn.value = true
|
||||
watchCallback()
|
||||
|
||||
expect(mockRefreshRemoteConfig).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('watch callback does not refresh when not logged in', async () => {
|
||||
await import('./cloudRemoteConfig')
|
||||
|
||||
const registeredExtension = mockRegisterExtension.mock.calls[0][0]
|
||||
await registeredExtension.setup()
|
||||
|
||||
const watchCallback = mockWatchDebounced.mock.calls[0][1]
|
||||
|
||||
mockIsLoggedIn.value = false
|
||||
watchCallback()
|
||||
|
||||
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -14,13 +14,13 @@ useExtensionService().registerExtension({
|
||||
|
||||
setup: async () => {
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
|
||||
// Refresh config when auth or subscription status changes
|
||||
// Primary auth refresh is handled by WorkspaceAuthGate on mount
|
||||
// This watcher handles subscription changes and acts as a backup for auth
|
||||
watchDebounced(
|
||||
[isLoggedIn, isActiveSubscription],
|
||||
[isLoggedIn, canAccessSubscriptionFeatures],
|
||||
() => {
|
||||
if (!isLoggedIn.value) return
|
||||
void refreshRemoteConfig()
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
}))
|
||||
|
||||
const subscriptionMocks = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
canAccessSubscriptionFeatures: { value: false },
|
||||
isInitialized: { value: true },
|
||||
subscriptionStatus: { value: null }
|
||||
}))
|
||||
@@ -101,7 +101,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQuery = {}
|
||||
subscriptionMocks.isActiveSubscription.value = false
|
||||
subscriptionMocks.canAccessSubscriptionFeatures.value = false
|
||||
subscriptionMocks.isInitialized.value = true
|
||||
})
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
})
|
||||
|
||||
test('opens billing portal when subscription is already active', async () => {
|
||||
subscriptionMocks.isActiveSubscription.value = true
|
||||
subscriptionMocks.canAccessSubscriptionFeatures.value = true
|
||||
|
||||
await mountView({ tier: 'creator' })
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ const router = useRouter()
|
||||
const { reportError, accessBillingPortal } = useAuthActions()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { isActiveSubscription, isInitialized, initialize } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures, isInitialized, initialize } =
|
||||
useBillingContext()
|
||||
|
||||
const selectedTierKey = ref<TierKey | null>(null)
|
||||
|
||||
@@ -78,7 +79,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
await initialize()
|
||||
}
|
||||
|
||||
if (isActiveSubscription.value) {
|
||||
if (canAccessSubscriptionFeatures.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(
|
||||
|
||||
@@ -22,7 +22,7 @@ function createDeferredPromise<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const mockIsActiveSubscription = ref(false)
|
||||
const mockCanAccessSubscriptionFeatures = ref(false)
|
||||
const mockSubscriptionTier = ref<
|
||||
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
|
||||
>(null)
|
||||
@@ -67,7 +67,9 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
canAccessSubscriptionFeatures: computed(
|
||||
() => mockCanAccessSubscriptionFeatures.value
|
||||
),
|
||||
isFreeTier: computed(() => false),
|
||||
subscriptionTier: computed(() => mockSubscriptionTier.value),
|
||||
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
|
||||
@@ -215,7 +217,7 @@ const onChooseTeamWorkspace = vi.fn()
|
||||
describe('PricingTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsActiveSubscription.value = false
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
mockSubscriptionTier.value = null
|
||||
mockIsYearlySubscription.value = false
|
||||
mockUserId.value = 'user-123'
|
||||
@@ -231,7 +233,7 @@ describe('PricingTable', () => {
|
||||
|
||||
describe('billing portal deep linking', () => {
|
||||
it('should call accessBillingPortal with yearly tier suffix when billing cycle is yearly (default)', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
|
||||
renderComponent()
|
||||
@@ -256,7 +258,7 @@ describe('PricingTable', () => {
|
||||
})
|
||||
|
||||
it('should call accessBillingPortal with different tiers correctly', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
|
||||
renderComponent()
|
||||
@@ -273,7 +275,7 @@ describe('PricingTable', () => {
|
||||
})
|
||||
|
||||
it('records the plan snapshot that was actually opened', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
|
||||
const portalOpen = createDeferredPromise<boolean>()
|
||||
@@ -313,7 +315,7 @@ describe('PricingTable', () => {
|
||||
})
|
||||
|
||||
it('does not record a pending upgrade when the billing portal does not open', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockAccessBillingPortal.mockResolvedValueOnce(false)
|
||||
|
||||
@@ -333,7 +335,7 @@ describe('PricingTable', () => {
|
||||
})
|
||||
|
||||
it('should use the latest userId value when it changes after mount', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'STANDARD'
|
||||
mockUserId.value = 'user-early'
|
||||
|
||||
@@ -360,7 +362,7 @@ describe('PricingTable', () => {
|
||||
})
|
||||
|
||||
it('should not call accessBillingPortal when clicking current plan', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
|
||||
renderComponent()
|
||||
@@ -377,7 +379,7 @@ describe('PricingTable', () => {
|
||||
})
|
||||
|
||||
it('should initiate checkout instead of billing portal for new subscribers', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
@@ -407,7 +409,7 @@ describe('PricingTable', () => {
|
||||
})
|
||||
|
||||
it('should pass correct tier for each subscription level', async () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscriptionTier.value = 'PRO'
|
||||
|
||||
renderComponent()
|
||||
|
||||
@@ -359,7 +359,7 @@ const tiers: PricingTierConfig[] = [
|
||||
}
|
||||
]
|
||||
const {
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isFreeTier,
|
||||
subscriptionTier,
|
||||
isYearlySubscription
|
||||
@@ -375,7 +375,7 @@ const popover = ref()
|
||||
const currentBillingCycle = ref<BillingCycle>('yearly')
|
||||
|
||||
const hasPaidSubscription = computed(
|
||||
() => isActiveSubscription.value && !isFreeTier.value
|
||||
() => canAccessSubscriptionFeatures.value && !isFreeTier.value
|
||||
)
|
||||
|
||||
const currentTierKey = computed<TierKey | null>(() =>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import SubscribeButton from './SubscribeButton.vue'
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = ref(false)
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
subscriptionTier: ref('FREE')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackSubscription: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
template:
|
||||
'<button :data-testid="$attrs[\'data-testid\']" @click="$emit(\'click\')"><slot /></button>',
|
||||
emits: ['click']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('SubscribeButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
})
|
||||
|
||||
function renderComponent(props: { label?: string } = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return render(SubscribeButton, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
},
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders subscribe button with default label', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Subscribe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders subscribe button with custom label', () => {
|
||||
renderComponent({ label: 'Custom Subscribe' })
|
||||
|
||||
expect(screen.getByText('Custom Subscribe')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('click behavior', () => {
|
||||
it('calls showSubscriptionDialog when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribed event', () => {
|
||||
it('emits subscribed when canAccessSubscriptionFeatures becomes true after clicking', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderComponent()
|
||||
|
||||
// Click to start awaiting
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Simulate subscription becoming active
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
|
||||
// Wait for watcher to trigger
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(emitted().subscribed).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -38,12 +38,13 @@ const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures, showSubscriptionDialog } =
|
||||
useBillingContext()
|
||||
const { subscriptionTier } = useSubscription()
|
||||
const isAwaitingStripeSubscription = ref(false)
|
||||
|
||||
watch(
|
||||
[isAwaitingStripeSubscription, isActiveSubscription],
|
||||
[isAwaitingStripeSubscription, canAccessSubscriptionFeatures],
|
||||
([awaiting, isActive]) => {
|
||||
if (isCloud && awaiting && isActive) {
|
||||
emit('subscribed')
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 mockCanAccessSubscriptionFeatures = ref(false)
|
||||
const mockIsCancelled = ref(false)
|
||||
const mockSubscriptionTier = ref<
|
||||
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
|
||||
@@ -24,7 +24,9 @@ const TIER_TO_NAME: Record<string, string> = {
|
||||
|
||||
// Mock composables - using computed to match composable return types
|
||||
const mockSubscriptionData = {
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
canAccessSubscriptionFeatures: computed(
|
||||
() => mockCanAccessSubscriptionFeatures.value
|
||||
),
|
||||
isCancelled: computed(() => mockIsCancelled.value),
|
||||
formattedRenewalDate: computed(() => '2024-12-31'),
|
||||
formattedEndDate: computed(() => '2024-12-31'),
|
||||
@@ -93,7 +95,9 @@ vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
|
||||
canAccessSubscriptionFeatures: computed(
|
||||
() => mockCanAccessSubscriptionFeatures.value
|
||||
),
|
||||
manageSubscription: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -227,7 +231,7 @@ describe('SubscriptionPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mock state
|
||||
mockIsActiveSubscription.value = false
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
mockIsCancelled.value = false
|
||||
mockSubscriptionTier.value = 'CREATOR'
|
||||
mockIsYearlySubscription.value = false
|
||||
@@ -237,14 +241,14 @@ describe('SubscriptionPanel', () => {
|
||||
|
||||
describe('subscription state functionality', () => {
|
||||
it('shows correct UI for active subscription', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('Manage Subscription')
|
||||
expect(container.textContent).toContain('Add Credits')
|
||||
})
|
||||
|
||||
it('shows correct UI for inactive subscription', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
const { container } = createComponent()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('subscribe-button-stub')).not.toBeNull()
|
||||
@@ -253,14 +257,14 @@ describe('SubscriptionPanel', () => {
|
||||
})
|
||||
|
||||
it('shows renewal date for active non-cancelled subscription', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsCancelled.value = false
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('Renews 2024-12-31')
|
||||
})
|
||||
|
||||
it('shows expiry date for cancelled subscription', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsCancelled.value = true
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toContain('Expires 2024-12-31')
|
||||
@@ -308,7 +312,7 @@ describe('SubscriptionPanel', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv('TZ', 'UTC')
|
||||
try {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
const { container } = createComponent()
|
||||
expect(container.textContent).toMatch(
|
||||
/Included \(Refills \d{2}\/\d{2}\/\d{2}\)/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-inter text-2xl/tight font-semibold">
|
||||
{{
|
||||
isActiveSubscription
|
||||
canAccessSubscriptionFeatures
|
||||
? $t('subscription.title')
|
||||
: $t('subscription.titleUnsubscribed')
|
||||
}}
|
||||
@@ -90,7 +90,8 @@ const teamWorkspacesEnabled = computed(
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { isActiveSubscription, manageSubscription } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures, manageSubscription } =
|
||||
useBillingContext()
|
||||
|
||||
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
|
||||
useSubscriptionActions()
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
v-if="canAccessSubscriptionFeatures"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isActiveSubscription && !isFreeTier"
|
||||
v-if="canAccessSubscriptionFeatures && !isFreeTier"
|
||||
variant="secondary"
|
||||
class="ml-auto rounded-lg bg-interface-menu-component-surface-selected px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="
|
||||
@@ -45,7 +45,7 @@
|
||||
{{ $t('subscription.manageSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
v-if="canAccessSubscriptionFeatures"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="showSubscriptionDialog"
|
||||
@@ -54,7 +54,7 @@
|
||||
</Button>
|
||||
|
||||
<SubscribeButton
|
||||
v-if="!isActiveSubscription"
|
||||
v-if="!canAccessSubscriptionFeatures"
|
||||
:label="$t('subscription.subscribeNow')"
|
||||
size="sm"
|
||||
:fluid="false"
|
||||
@@ -141,7 +141,7 @@
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isActiveSubscription && isFreeTier"
|
||||
v-if="canAccessSubscriptionFeatures && isFreeTier"
|
||||
variant="gradient"
|
||||
class="min-h-8 w-full rounded-lg p-2 text-sm font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
@@ -149,7 +149,7 @@
|
||||
{{ $t('subscription.upgradeToAddCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
v-else-if="canAccessSubscriptionFeatures"
|
||||
variant="secondary"
|
||||
class="min-h-8 rounded-lg bg-interface-menu-component-surface-selected p-2 text-sm font-normal text-text-primary"
|
||||
@click="handleAddApiCredits"
|
||||
@@ -232,7 +232,7 @@ const authActions = useAuthActions()
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isCancelled,
|
||||
isFreeTier,
|
||||
formattedRenewalDate,
|
||||
|
||||
@@ -169,7 +169,7 @@ const emit = defineEmits<{
|
||||
close: [subscribed: boolean]
|
||||
}>()
|
||||
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
|
||||
const isSubscriptionEnabled = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
@@ -191,7 +191,7 @@ const telemetry = useTelemetry()
|
||||
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
|
||||
|
||||
watch(
|
||||
() => isActiveSubscription.value,
|
||||
() => canAccessSubscriptionFeatures.value,
|
||||
(isActive) => {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
emit('close', true)
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('useSubscription', () => {
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should compute isActiveSubscription correctly when subscription is active', async () => {
|
||||
it('should compute canAccessSubscriptionFeatures correctly when subscription is active', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -206,13 +206,14 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope()
|
||||
const { canAccessSubscriptionFeatures, fetchStatus } =
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
expect(canAccessSubscriptionFeatures.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should compute isActiveSubscription as false when subscription is inactive', async () => {
|
||||
it('should compute canAccessSubscriptionFeatures as false when subscription is inactive', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -223,10 +224,11 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope()
|
||||
const { canAccessSubscriptionFeatures, fetchStatus } =
|
||||
useSubscriptionWithScope()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isActiveSubscription.value).toBe(false)
|
||||
expect(canAccessSubscriptionFeatures.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should format renewal date correctly', async () => {
|
||||
@@ -604,12 +606,12 @@ describe('useSubscription', () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should report isActiveSubscription as true when not on cloud', () => {
|
||||
it('should report canAccessSubscriptionFeatures as true when not on cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
const { isActiveSubscription } = useSubscriptionWithScope()
|
||||
const { canAccessSubscriptionFeatures } = useSubscriptionWithScope()
|
||||
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
expect(canAccessSubscriptionFeatures.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ function useSubscriptionInternal() {
|
||||
const telemetry = useTelemetry()
|
||||
const isInitialized = ref(false)
|
||||
|
||||
const isSubscribedOrIsNotCloud = computed(() => {
|
||||
const canAccessSubscriptionFeatures = computed(() => {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
@@ -222,7 +222,7 @@ function useSubscriptionInternal() {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'standard',
|
||||
cycle: 'monthly',
|
||||
checkout_type: isSubscribedOrIsNotCloud.value ? 'change' : 'new',
|
||||
checkout_type: canAccessSubscriptionFeatures.value ? 'change' : 'new',
|
||||
...(subscriptionTier.value
|
||||
? { previous_tier: TIER_TO_KEY[subscriptionTier.value] }
|
||||
: {}),
|
||||
@@ -257,7 +257,7 @@ function useSubscriptionInternal() {
|
||||
const { startCancellationWatcher, stopCancellationWatcher } =
|
||||
useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
canAccessSubscriptionFeatures,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation: isSubscriptionEnabled
|
||||
@@ -275,7 +275,7 @@ function useSubscriptionInternal() {
|
||||
const requireActiveSubscription = async (): Promise<void> => {
|
||||
await fetchSubscriptionStatus()
|
||||
|
||||
if (!isSubscribedOrIsNotCloud.value) {
|
||||
if (!canAccessSubscriptionFeatures.value) {
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
@@ -439,7 +439,7 @@ function useSubscriptionInternal() {
|
||||
|
||||
return {
|
||||
// State
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
canAccessSubscriptionFeatures,
|
||||
isInitialized,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
baseStatus
|
||||
)
|
||||
const isActive = ref(true)
|
||||
const isActiveSubscription = computed(() => isActive.value)
|
||||
const canAccessSubscriptionFeatures = computed(() => isActive.value)
|
||||
|
||||
let shouldWatch = true
|
||||
const shouldWatchCancellation = () => shouldWatch
|
||||
@@ -77,7 +77,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
@@ -107,7 +107,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
@@ -129,7 +129,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
@@ -154,7 +154,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
|
||||
@@ -12,7 +12,7 @@ const CANCELLATION_BACKOFF_MULTIPLIER = 3 // 5s, 15s, 45s, 135s intervals
|
||||
|
||||
type CancellationWatcherOptions = {
|
||||
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
canAccessSubscriptionFeatures: ComputedRef<boolean>
|
||||
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
|
||||
telemetry: Pick<
|
||||
TelemetryDispatcher,
|
||||
@@ -23,7 +23,7 @@ type CancellationWatcherOptions = {
|
||||
|
||||
export function useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation
|
||||
@@ -76,7 +76,7 @@ export function useSubscriptionCancellationWatcher({
|
||||
try {
|
||||
await fetchStatus()
|
||||
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!canAccessSubscriptionFeatures.value) {
|
||||
if (!cancellationTracked.value) {
|
||||
cancellationTracked.value = true
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
useBillingContext: () => ({ canAccessSubscriptionFeatures: ref(false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useSettingUI(
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
@@ -154,7 +154,7 @@ export function useSettingUI(
|
||||
|
||||
const shouldShowPlanCreditsPanel = computed(() => {
|
||||
if (!subscriptionPanel) return false
|
||||
return isActiveSubscription.value
|
||||
return canAccessSubscriptionFeatures.value
|
||||
})
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopoverWorkspace from './CurrentUserPopoverWorkspace.vue'
|
||||
|
||||
// Mock pinia - preserve actual exports to avoid missing defineStore
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: vi.fn((store) => store)
|
||||
}
|
||||
})
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = ref(true)
|
||||
const mockIsFreeTier = ref(false)
|
||||
const mockSubscription = ref({ isCancelled: false })
|
||||
const mockBalance = ref({ effectiveBalanceMicros: 100000 })
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
isFreeTier: mockIsFreeTier,
|
||||
subscription: mockSubscription,
|
||||
balance: mockBalance,
|
||||
isLoading: ref(false),
|
||||
fetchBalance: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
initState: ref('ready'),
|
||||
workspaceName: ref('Test Workspace'),
|
||||
isInPersonalWorkspace: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: ref({
|
||||
canTopUp: true,
|
||||
canManageSubscription: true
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userDisplayName: 'Test User',
|
||||
userEmail: 'test@example.com',
|
||||
userPhotoUrl: null,
|
||||
handleSignOut: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: () => ({
|
||||
show: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
showCreateWorkspaceDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
showPricingTable: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
buildDocsUrl: vi.fn().mockReturnValue('https://docs.example.com'),
|
||||
docsPaths: { partnerNodesPricing: '/pricing' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
formatCreditsFromCents: vi.fn().mockReturnValue('$10.00')
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: { template: '<div data-testid="user-avatar"></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceProfilePic.vue', () => ({
|
||||
default: { template: '<div data-testid="workspace-pic"></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceSwitcherPopover.vue', () => ({
|
||||
default: { template: '<div data-testid="workspace-switcher"></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
template: '<button :data-testid="$attrs[\'data-testid\']"><slot /></button>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
default: {
|
||||
template: '<button data-testid="subscribe-button">Subscribe</button>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: { template: '<hr />' }
|
||||
}))
|
||||
|
||||
vi.mock('primevue/popover', () => ({
|
||||
default: { template: '<div><slot /></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('primevue/skeleton', () => ({
|
||||
default: { template: '<div data-testid="skeleton"></div>' }
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopoverWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockSubscription.value = { isCancelled: false }
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return render(CurrentUserPopoverWorkspace, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('canAccessSubscriptionFeatures', () => {
|
||||
it('shows add credits button when canAccessSubscriptionFeatures is true and not free tier', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('upgrade-to-add-credits-button')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows upgrade button when canAccessSubscriptionFeatures is true and is free tier', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = true
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByTestId('upgrade-to-add-credits-button')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-credits-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows manage plan when canAccessSubscriptionFeatures is true', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('manage-plan-menu-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides manage plan when canAccessSubscriptionFeatures is false', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('manage-plan-menu-item')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -80,7 +80,9 @@
|
||||
</Button>
|
||||
<!-- Upgrade to add credits (free tier) -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
|
||||
v-if="
|
||||
canAccessSubscriptionFeatures && permissions.canTopUp && isFreeTier
|
||||
"
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@@ -90,7 +92,7 @@
|
||||
</Button>
|
||||
<!-- Add Credits (subscribed + personal or workspace owner only, paid tier) -->
|
||||
<Button
|
||||
v-else-if="isActiveSubscription && permissions.canTopUp"
|
||||
v-else-if="canAccessSubscriptionFeatures && permissions.canTopUp"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
@@ -259,7 +261,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const dialogService = useDialogService()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isFreeTier,
|
||||
subscription,
|
||||
balance,
|
||||
@@ -299,12 +301,14 @@ const showPlansAndPricing = computed(
|
||||
() => permissions.value.canManageSubscription
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => permissions.value.canManageSubscription && isActiveSubscription.value
|
||||
() =>
|
||||
permissions.value.canManageSubscription &&
|
||||
canAccessSubscriptionFeatures.value
|
||||
)
|
||||
const showSubscribeAction = computed(
|
||||
() =>
|
||||
permissions.value.canManageSubscription &&
|
||||
(!isActiveSubscription.value || isCancelled.value)
|
||||
(!canAccessSubscriptionFeatures.value || isCancelled.value)
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isActiveSubscription"
|
||||
v-if="canAccessSubscriptionFeatures"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm',
|
||||
@@ -125,7 +125,10 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
v-if="
|
||||
canAccessSubscriptionFeatures &&
|
||||
permissions.canManageSubscription
|
||||
"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: show only Resubscribe button -->
|
||||
@@ -248,7 +251,7 @@
|
||||
|
||||
<div
|
||||
v-if="
|
||||
isActiveSubscription &&
|
||||
canAccessSubscriptionFeatures &&
|
||||
!showZeroState &&
|
||||
permissions.canTopUp
|
||||
"
|
||||
@@ -275,7 +278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isActiveSubscription" class="flex flex-col gap-2">
|
||||
<div v-if="canAccessSubscriptionFeatures" class="flex flex-col gap-2">
|
||||
<div class="text-sm text-text-primary">
|
||||
{{ $t('subscription.yourPlanIncludes') }}
|
||||
</div>
|
||||
@@ -312,7 +315,7 @@
|
||||
<!-- Members invoice card -->
|
||||
<div
|
||||
v-if="
|
||||
isActiveSubscription &&
|
||||
canAccessSubscriptionFeatures &&
|
||||
!isInPersonalWorkspace &&
|
||||
permissions.canManageSubscription
|
||||
"
|
||||
@@ -397,7 +400,7 @@ const billingOperationStore = useBillingOperationStore()
|
||||
const isSettingUp = computed(() => billingOperationStore.isSettingUp)
|
||||
|
||||
const {
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isFreeTier: isFreeTierPlan,
|
||||
subscription,
|
||||
showSubscriptionDialog,
|
||||
@@ -447,7 +450,7 @@ const isCancelled = computed(
|
||||
const showSubscribePrompt = computed(() => {
|
||||
if (!permissions.value.canManageSubscription) return false
|
||||
if (isCancelled.value) return false
|
||||
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
|
||||
if (isInPersonalWorkspace.value) return !canAccessSubscriptionFeatures.value
|
||||
return !isWorkspaceSubscribed.value
|
||||
})
|
||||
|
||||
@@ -455,7 +458,7 @@ const showSubscribePrompt = computed(() => {
|
||||
const isMemberView = computed(
|
||||
() =>
|
||||
!permissions.value.canManageSubscription &&
|
||||
!isActiveSubscription.value &&
|
||||
!canAccessSubscriptionFeatures.value &&
|
||||
!isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import InviteMemberUpsellDialogContent from './InviteMemberUpsellDialogContent.vue'
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = ref(true)
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
template:
|
||||
'<button :data-testid="$attrs[\'data-testid\']" @click="$emit(\'click\')"><slot /></button>',
|
||||
emits: ['click']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('InviteMemberUpsellDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
|
||||
return render(InviteMemberUpsellDialogContent, {
|
||||
global: {
|
||||
plugins: [i18n, pinia]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('canAccessSubscriptionFeatures', () => {
|
||||
it('shows single seat message when canAccessSubscriptionFeatures is true', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
renderComponent()
|
||||
|
||||
// Should show single seat title (user has subscription but single seat plan)
|
||||
expect(
|
||||
screen.getByText('Your current plan supports a single seat')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows not subscribed message when canAccessSubscriptionFeatures is false', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
renderComponent()
|
||||
|
||||
// Should show not subscribed title (user doesn't have subscription)
|
||||
expect(
|
||||
screen.getByText('A subscription is required to invite members')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows upgradeToCreator button when canAccessSubscriptionFeatures is true', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Upgrade to Creator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows viewPlans button when canAccessSubscriptionFeatures is false', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('View Plans')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
canAccessSubscriptionFeatures
|
||||
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
|
||||
}}
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
isActiveSubscription
|
||||
canAccessSubscriptionFeatures
|
||||
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
|
||||
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
|
||||
}}
|
||||
@@ -40,7 +40,7 @@
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onUpgrade">
|
||||
{{
|
||||
isActiveSubscription
|
||||
canAccessSubscriptionFeatures
|
||||
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
|
||||
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
|
||||
}}
|
||||
@@ -55,7 +55,8 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures, showSubscriptionDialog } =
|
||||
useBillingContext()
|
||||
|
||||
function onDismiss() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<p class="text-foreground m-0 text-sm">
|
||||
{{
|
||||
isActiveSubscription
|
||||
canAccessSubscriptionFeatures
|
||||
? $t('workspacePanel.members.upsellBannerUpgrade')
|
||||
: $t('workspacePanel.members.upsellBannerSubscribe')
|
||||
}}
|
||||
@@ -23,7 +23,7 @@
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
isActiveSubscription: boolean
|
||||
canAccessSubscriptionFeatures: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -92,7 +92,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
pendingInvites: mockPendingInvites,
|
||||
permissions: mockPermissions,
|
||||
uiConfig: mockUiConfig,
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
canAccessSubscriptionFeatures: mockIsActiveSubscription,
|
||||
userPhotoUrl: ref(null),
|
||||
isCurrentUser: (m: WorkspaceMember) =>
|
||||
m.email.toLowerCase() === 'owner@example.com',
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
<!-- Upsell Banner -->
|
||||
<MemberUpsellBanner
|
||||
v-if="isSingleSeatPlan"
|
||||
:is-active-subscription="isActiveSubscription"
|
||||
:can-access-subscription-features="canAccessSubscriptionFeatures"
|
||||
@show-plans="showSubscriptionDialog()"
|
||||
/>
|
||||
|
||||
@@ -224,7 +224,7 @@ const {
|
||||
pendingInvites,
|
||||
permissions,
|
||||
uiConfig,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
userPhotoUrl,
|
||||
isCurrentUser,
|
||||
selectMember,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
|
||||
// Mock pinia - preserve actual exports to avoid missing defineStore
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: vi.fn((store) => store)
|
||||
}
|
||||
})
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = ref(true)
|
||||
const mockSubscription = ref({ tier: 'PRO' })
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
subscription: mockSubscription,
|
||||
getMaxSeats: vi.fn().mockReturnValue(20)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/constants/tierPricing', () => ({
|
||||
TIER_TO_KEY: { PRO: 'pro', STANDARD: 'standard' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
workspaceName: ref('Test Workspace'),
|
||||
members: ref([{ id: '1', name: 'User 1' }]),
|
||||
isInviteLimitReached: ref(false),
|
||||
isWorkspaceSubscribed: ref(true),
|
||||
fetchMembers: vi.fn(),
|
||||
fetchPendingInvites: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceRole: ref('owner'),
|
||||
permissions: ref({
|
||||
canInviteMembers: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}),
|
||||
uiConfig: ref({
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showLeaveWorkspaceDialog: vi.fn(),
|
||||
showDeleteWorkspaceDialog: vi.fn(),
|
||||
showInviteMemberDialog: vi.fn(),
|
||||
showInviteMemberUpsellDialog: vi.fn(),
|
||||
showEditWorkspaceDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
default: { template: '<div data-testid="profile-pic"></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('./MembersPanelContent.vue', () => ({
|
||||
default: { template: '<div data-testid="members-panel"></div>' }
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
default: { template: '<div data-testid="subscription-panel"></div>' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: { template: '<button><slot /></button>' }
|
||||
}))
|
||||
|
||||
vi.mock('primevue/menu', () => ({
|
||||
default: {
|
||||
template: '<div data-testid="menu"><slot name="item" :item="{}" /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('reka-ui', () => ({
|
||||
TabsRoot: { template: '<div><slot /></div>' },
|
||||
TabsList: { template: '<div><slot /></div>' },
|
||||
TabsTrigger: { template: '<button><slot /></button>' },
|
||||
TabsContent: { template: '<div><slot /></div>' }
|
||||
}))
|
||||
|
||||
describe('WorkspacePanelContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscription.value = { tier: 'PRO' }
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return render(WorkspacePanelContent, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('isSingleSeatPlan computed', () => {
|
||||
it('returns false when canAccessSubscriptionFeatures is true and tier has multiple seats', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscription.value = { tier: 'PRO' }
|
||||
renderComponent()
|
||||
|
||||
// The component renders which means isSingleSeatPlan computed correctly
|
||||
expect(screen.getByTestId('profile-pic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles when canAccessSubscriptionFeatures is false', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
renderComponent()
|
||||
|
||||
// Component still renders
|
||||
expect(screen.getByTestId('profile-pic')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -151,10 +151,11 @@ const {
|
||||
showInviteMemberUpsellDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures, subscription, getMaxSeats } =
|
||||
useBillingContext()
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (!isActiveSubscription.value) return true
|
||||
if (!canAccessSubscriptionFeatures.value) return true
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return true
|
||||
const tierKey = TIER_TO_KEY[tier]
|
||||
@@ -184,7 +185,7 @@ function handleEditWorkspace() {
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||
// Use workspace's own subscription status, not the global canAccessSubscriptionFeatures
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
|
||||
@@ -214,7 +214,7 @@ const {
|
||||
mockIsInPersonalWorkspace,
|
||||
mockPermissions,
|
||||
mockUiConfig,
|
||||
mockIsActiveSubscription,
|
||||
mockCanAccessSubscriptionFeatures,
|
||||
mockSubscription
|
||||
} = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
@@ -248,7 +248,7 @@ const {
|
||||
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
|
||||
workspaceMenuDisabledTooltip: null as string | null
|
||||
}),
|
||||
mockIsActiveSubscription: ref(true),
|
||||
mockCanAccessSubscriptionFeatures: ref(true),
|
||||
mockSubscription: ref<{ tier: string } | null>({ tier: 'PRO' })
|
||||
}
|
||||
})
|
||||
@@ -295,7 +295,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
subscription: mockSubscription,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog,
|
||||
getMaxSeats: (tierKey: string) => {
|
||||
@@ -334,7 +334,7 @@ describe('useMembersPanel', () => {
|
||||
mockMembers.value = []
|
||||
mockPendingInvites.value = []
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockSubscription.value = { tier: 'PRO' }
|
||||
})
|
||||
|
||||
@@ -352,7 +352,7 @@ describe('useMembersPanel', () => {
|
||||
})
|
||||
|
||||
it('is true when no active subscription', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
const panel = await setup()
|
||||
expect(panel.isSingleSeatPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useMembersPanel() {
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const {
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
subscription,
|
||||
showSubscriptionDialog,
|
||||
getMaxSeats
|
||||
@@ -102,7 +102,7 @@ export function useMembersPanel() {
|
||||
|
||||
const isSingleSeatPlan = computed(() => {
|
||||
if (isPersonalWorkspace.value) return false
|
||||
if (!isActiveSubscription.value) return true
|
||||
if (!canAccessSubscriptionFeatures.value) return true
|
||||
return maxSeats.value <= 1
|
||||
})
|
||||
|
||||
@@ -205,7 +205,7 @@ export function useMembersPanel() {
|
||||
pendingInvites,
|
||||
permissions,
|
||||
uiConfig,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
userPhotoUrl,
|
||||
isCurrentUser,
|
||||
selectMember,
|
||||
|
||||
@@ -165,7 +165,7 @@ describe('useWorkspaceBilling', () => {
|
||||
it('exposes a null subscription before any status fetch', () => {
|
||||
const billing = setupBilling()
|
||||
expect(billing.subscription.value).toBeNull()
|
||||
expect(billing.isActiveSubscription.value).toBe(false)
|
||||
expect(billing.canAccessSubscriptionFeatures.value).toBe(false)
|
||||
expect(billing.isFreeTier.value).toBe(false)
|
||||
})
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('useWorkspaceBilling', () => {
|
||||
isCancelled: true,
|
||||
hasFunds: true
|
||||
})
|
||||
expect(billing.isActiveSubscription.value).toBe(true)
|
||||
expect(billing.canAccessSubscriptionFeatures.value).toBe(true)
|
||||
expect(billing.isFreeTier.value).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
const statusData = shallowRef<BillingStatusResponse | null>(null)
|
||||
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(
|
||||
const canAccessSubscriptionFeatures = computed(
|
||||
() => statusData.value?.is_active ?? false
|
||||
)
|
||||
const isFreeTier = computed(
|
||||
@@ -279,7 +279,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!canAccessSubscriptionFeatures.value) {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
canAccessSubscriptionFeatures,
|
||||
isFreeTier,
|
||||
|
||||
// Actions
|
||||
|
||||
147
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
147
src/renderer/extensions/linearMode/LinearControls.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import LinearControls from './LinearControls.vue'
|
||||
|
||||
// Mock all dependencies - preserve actual exports to avoid missing defineStore
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: vi.fn((store) => store)
|
||||
}
|
||||
})
|
||||
|
||||
const mockCanAccessSubscriptionFeatures = ref(true)
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueSettingsStore: () => ({
|
||||
batchCount: ref(1)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue(10)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: { filename: 'test.json' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
isBuilderMode: ref(false)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({
|
||||
hasOutputs: ref(true)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/components/builder/AppModeWidgetList.vue', () => ({
|
||||
default: { template: '<div data-testid="widget-list"></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/loader/Loader.vue', () => ({
|
||||
default: { template: '<div data-testid="loader"></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/ScrubableNumberInput.vue', () => ({
|
||||
default: { template: '<input data-testid="number-input" />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/Popover.vue', () => ({
|
||||
default: { template: '<div><slot name="button" /><slot /></div>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: { template: '<button><slot /></button>' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
|
||||
default: { template: '<div data-testid="subscribe-to-run">Subscribe</div>' }
|
||||
}))
|
||||
|
||||
vi.mock('./PartnerNodesList.vue', () => ({
|
||||
default: { template: '<div data-testid="partner-nodes"></div>' }
|
||||
}))
|
||||
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
})
|
||||
|
||||
function renderComponent(props: { mobile?: boolean } = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return render(LinearControls, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
},
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
describe('canAccessSubscriptionFeatures', () => {
|
||||
it('hides SubscribeToRunButton when canAccessSubscriptionFeatures is true', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByTestId('subscribe-to-run')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows SubscribeToRunButton when canAccessSubscriptionFeatures is false', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows SubscribeToRunButton on mobile when canAccessSubscriptionFeatures is false', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
renderComponent({ mobile: true })
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides SubscribeToRunButton on mobile when canAccessSubscriptionFeatures is true', () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
renderComponent({ mobile: true })
|
||||
|
||||
expect(screen.queryByTestId('subscribe-to-run')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { canAccessSubscriptionFeatures } = useBillingContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
@@ -137,7 +137,7 @@ function handleDragDrop() {
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
v-if="!canAccessSubscriptionFeatures"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<div v-else class="mt-4 flex">
|
||||
@@ -189,7 +189,7 @@ function handleDragDrop() {
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
v-if="!canAccessSubscriptionFeatures"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<Button
|
||||
|
||||
266
src/schemas/nodeDefSchema.test.ts
Normal file
266
src/schemas/nodeDefSchema.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
ComboInputSpecV2,
|
||||
InputSpec
|
||||
} from './nodeDefSchema'
|
||||
import {
|
||||
getComboSpecComboOptions,
|
||||
getInputSpecType,
|
||||
isComboInputSpec,
|
||||
isComboInputSpecV1,
|
||||
isComboInputSpecV2,
|
||||
isFloatInputSpec,
|
||||
isIntInputSpec,
|
||||
isMediaUploadComboInput,
|
||||
validateComfyNodeDef
|
||||
} from './nodeDefSchema'
|
||||
|
||||
describe('nodeDefSchema', () => {
|
||||
describe('isComboInputSpecV1', () => {
|
||||
it('returns true for legacy combo spec with array', () => {
|
||||
const spec: InputSpec = [['option1', 'option2'], {}]
|
||||
expect(isComboInputSpecV1(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for v2 combo spec', () => {
|
||||
const spec: InputSpec = ['COMBO', { options: ['a', 'b'] }]
|
||||
expect(isComboInputSpecV1(spec)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for INT spec', () => {
|
||||
const spec: InputSpec = ['INT', {}]
|
||||
expect(isComboInputSpecV1(spec)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIntInputSpec', () => {
|
||||
it('returns true for INT spec', () => {
|
||||
const spec: InputSpec = ['INT', { min: 0, max: 100 }]
|
||||
expect(isIntInputSpec(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for FLOAT spec', () => {
|
||||
const spec: InputSpec = ['FLOAT', {}]
|
||||
expect(isIntInputSpec(spec)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFloatInputSpec', () => {
|
||||
it('returns true for FLOAT spec', () => {
|
||||
const spec: InputSpec = ['FLOAT', { min: 0.0, max: 1.0 }]
|
||||
expect(isFloatInputSpec(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for INT spec', () => {
|
||||
const spec: InputSpec = ['INT', {}]
|
||||
expect(isFloatInputSpec(spec)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isComboInputSpecV2', () => {
|
||||
it('returns true for COMBO spec', () => {
|
||||
const spec: InputSpec = ['COMBO', { options: ['a', 'b'] }]
|
||||
expect(isComboInputSpecV2(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for legacy combo spec', () => {
|
||||
const spec: InputSpec = [['a', 'b'], {}]
|
||||
expect(isComboInputSpecV2(spec)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isComboInputSpec', () => {
|
||||
it('returns true for v1 combo spec', () => {
|
||||
const spec: InputSpec = [['option1', 'option2'], {}]
|
||||
expect(isComboInputSpec(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for v2 combo spec', () => {
|
||||
const spec: InputSpec = ['COMBO', { options: ['a', 'b'] }]
|
||||
expect(isComboInputSpec(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for INT spec', () => {
|
||||
const spec: InputSpec = ['INT', {}]
|
||||
expect(isComboInputSpec(spec)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMediaUploadComboInput', () => {
|
||||
it('returns true for image_upload combo v1', () => {
|
||||
const spec: InputSpec = [['img1.png', 'img2.png'], { image_upload: true }]
|
||||
expect(isMediaUploadComboInput(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for video_upload combo v2', () => {
|
||||
const spec: InputSpec = ['COMBO', { video_upload: true }]
|
||||
expect(isMediaUploadComboInput(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for animated_image_upload combo', () => {
|
||||
const spec: InputSpec = [['gif1.gif'], { animated_image_upload: true }]
|
||||
expect(isMediaUploadComboInput(spec)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when options is undefined', () => {
|
||||
const spec: InputSpec = ['COMBO', undefined]
|
||||
expect(isMediaUploadComboInput(spec)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-upload combo', () => {
|
||||
const spec: InputSpec = ['COMBO', { options: ['a', 'b'] }]
|
||||
expect(isMediaUploadComboInput(spec)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for INT with upload flag', () => {
|
||||
const spec: InputSpec = ['INT', { image_upload: true } as never]
|
||||
expect(isMediaUploadComboInput(spec)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputSpecType', () => {
|
||||
it('returns COMBO for v1 combo spec', () => {
|
||||
const spec: InputSpec = [['a', 'b'], {}]
|
||||
expect(getInputSpecType(spec)).toBe('COMBO')
|
||||
})
|
||||
|
||||
it('returns COMBO for v2 combo spec', () => {
|
||||
const spec: InputSpec = ['COMBO', {}]
|
||||
expect(getInputSpecType(spec)).toBe('COMBO')
|
||||
})
|
||||
|
||||
it('returns INT for INT spec', () => {
|
||||
const spec: InputSpec = ['INT', {}]
|
||||
expect(getInputSpecType(spec)).toBe('INT')
|
||||
})
|
||||
|
||||
it('returns FLOAT for FLOAT spec', () => {
|
||||
const spec: InputSpec = ['FLOAT', {}]
|
||||
expect(getInputSpecType(spec)).toBe('FLOAT')
|
||||
})
|
||||
|
||||
it('returns custom type for custom spec', () => {
|
||||
const spec: InputSpec = ['CUSTOM_TYPE', {}]
|
||||
expect(getInputSpecType(spec)).toBe('CUSTOM_TYPE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComboSpecComboOptions', () => {
|
||||
it('returns options from v1 combo spec', () => {
|
||||
const spec: ComboInputSpec = [['opt1', 'opt2', 123], {}]
|
||||
expect(getComboSpecComboOptions(spec)).toEqual(['opt1', 'opt2', 123])
|
||||
})
|
||||
|
||||
it('returns options from v2 combo spec', () => {
|
||||
const spec: ComboInputSpecV2 = ['COMBO', { options: ['a', 'b', 'c'] }]
|
||||
expect(getComboSpecComboOptions(spec)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('returns empty array when v2 has no options', () => {
|
||||
const spec: ComboInputSpecV2 = ['COMBO', {}]
|
||||
expect(getComboSpecComboOptions(spec)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when v2 options is undefined', () => {
|
||||
const spec: ComboInputSpecV2 = ['COMBO', undefined]
|
||||
expect(getComboSpecComboOptions(spec)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateComfyNodeDef', () => {
|
||||
const validNodeDef = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: 'A test node',
|
||||
category: 'test',
|
||||
output_node: false,
|
||||
python_module: 'test_module'
|
||||
}
|
||||
|
||||
it('returns validated node def for valid input', () => {
|
||||
const result = validateComfyNodeDef(validNodeDef)
|
||||
expect(result).toEqual(validNodeDef)
|
||||
})
|
||||
|
||||
it('returns node def with optional fields', () => {
|
||||
const nodeWithOptional = {
|
||||
...validNodeDef,
|
||||
deprecated: true,
|
||||
experimental: true,
|
||||
api_node: true,
|
||||
help: 'Some help text'
|
||||
}
|
||||
const result = validateComfyNodeDef(nodeWithOptional)
|
||||
expect(result).toEqual(nodeWithOptional)
|
||||
})
|
||||
|
||||
it('returns null and calls onError for invalid input', () => {
|
||||
const onError = vi.fn()
|
||||
const invalidDef = { name: 'Test' }
|
||||
const result = validateComfyNodeDef(invalidDef, onError)
|
||||
expect(result).toBeNull()
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses console.warn as default error handler', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const invalidDef = { name: 'Test' }
|
||||
validateComfyNodeDef(invalidDef)
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('validates input specs correctly', () => {
|
||||
const nodeWithInputs = {
|
||||
...validNodeDef,
|
||||
input: {
|
||||
required: {
|
||||
seed: ['INT', { min: 0, max: 1000 }],
|
||||
prompt: ['STRING', { multiline: true }]
|
||||
},
|
||||
optional: {
|
||||
model: ['COMBO', { options: ['model1', 'model2'] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = validateComfyNodeDef(nodeWithInputs)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.input?.required?.seed).toEqual([
|
||||
'INT',
|
||||
{ min: 0, max: 1000 }
|
||||
])
|
||||
})
|
||||
|
||||
it('validates output specs correctly', () => {
|
||||
const nodeWithOutputs = {
|
||||
...validNodeDef,
|
||||
output: ['IMAGE', 'MASK'],
|
||||
output_name: ['image', 'mask'],
|
||||
output_is_list: [false, false]
|
||||
}
|
||||
const result = validateComfyNodeDef(nodeWithOutputs)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.output).toEqual(['IMAGE', 'MASK'])
|
||||
})
|
||||
|
||||
it('validates price_badge correctly', () => {
|
||||
const nodeWithPriceBadge = {
|
||||
...validNodeDef,
|
||||
api_node: true,
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'width', type: 'INT' }],
|
||||
inputs: ['image']
|
||||
},
|
||||
expr: '$round(w.width * 0.001, 4)'
|
||||
}
|
||||
}
|
||||
const result = validateComfyNodeDef(nodeWithPriceBadge)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.price_badge?.expr).toBe('$round(w.width * 0.001, 4)')
|
||||
})
|
||||
})
|
||||
})
|
||||
177
src/services/dialogService.test.ts
Normal file
177
src/services/dialogService.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCanAccessSubscriptionFeatures,
|
||||
mockIsFreeTier,
|
||||
mockBillingType,
|
||||
mockShowDialog,
|
||||
mockSubscriptionDialogShow
|
||||
} = vi.hoisted(() => ({
|
||||
mockCanAccessSubscriptionFeatures: { value: true },
|
||||
mockIsFreeTier: { value: false },
|
||||
mockBillingType: { value: 'legacy' as 'legacy' | 'workspace' },
|
||||
mockShowDialog: vi.fn(),
|
||||
mockSubscriptionDialogShow: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
|
||||
isFreeTier: mockIsFreeTier,
|
||||
type: mockBillingType
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: mockShowDialog,
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackEvent: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isFreeTier: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
showPricingTable: vi.fn(),
|
||||
show: mockSubscriptionDialogShow
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isInPersonalWorkspace: { value: true }
|
||||
})
|
||||
}))
|
||||
|
||||
// Import once at top level
|
||||
import { useDialogService } from './dialogService'
|
||||
|
||||
describe('dialogService', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockBillingType.value = 'legacy'
|
||||
// Set up window.__CONFIG__ for subscription_required check
|
||||
;(
|
||||
globalThis as unknown as {
|
||||
__CONFIG__: { subscription_required: boolean }
|
||||
}
|
||||
).__CONFIG__ = {
|
||||
subscription_required: true
|
||||
}
|
||||
})
|
||||
|
||||
describe('showTopUpCreditsDialog', () => {
|
||||
it('shows top up dialog for legacy billing when canAccessSubscriptionFeatures is true', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockBillingType.value = 'legacy'
|
||||
|
||||
const dialogService = useDialogService()
|
||||
await dialogService.showTopUpCreditsDialog()
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'top-up-credits'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows workspace top up dialog when type is workspace', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockBillingType.value = 'workspace'
|
||||
|
||||
const dialogService = useDialogService()
|
||||
await dialogService.showTopUpCreditsDialog()
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'top-up-credits'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes options to dialog when canAccessSubscriptionFeatures', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = false
|
||||
|
||||
const dialogService = useDialogService()
|
||||
await dialogService.showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'top-up-credits',
|
||||
props: { isInsufficientCredits: true }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows subscription dialog when canAccessSubscriptionFeatures is false', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
mockIsFreeTier.value = false
|
||||
|
||||
const dialogService = useDialogService()
|
||||
await dialogService.showTopUpCreditsDialog()
|
||||
|
||||
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
|
||||
expect(mockShowDialog).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'top-up-credits' })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows subscription dialog when isFreeTier is true', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = true
|
||||
mockIsFreeTier.value = true
|
||||
|
||||
const dialogService = useDialogService()
|
||||
await dialogService.showTopUpCreditsDialog()
|
||||
|
||||
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
|
||||
expect(mockShowDialog).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'top-up-credits' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes out_of_credits reason to subscription dialog when isInsufficientCredits', async () => {
|
||||
mockCanAccessSubscriptionFeatures.value = false
|
||||
mockIsFreeTier.value = false
|
||||
|
||||
const dialogService = useDialogService()
|
||||
await dialogService.showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
expect(mockSubscriptionDialogShow).toHaveBeenCalledWith({
|
||||
reason: 'out_of_credits'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -301,8 +301,9 @@ export const useDialogService = () => {
|
||||
async function showTopUpCreditsDialog(options?: {
|
||||
isInsufficientCredits?: boolean
|
||||
}) {
|
||||
const { isActiveSubscription, isFreeTier, type } = useBillingContext()
|
||||
if (!isActiveSubscription.value || isFreeTier.value) {
|
||||
const { canAccessSubscriptionFeatures, isFreeTier, type } =
|
||||
useBillingContext()
|
||||
if (!canAccessSubscriptionFeatures.value || isFreeTier.value) {
|
||||
await showSubscriptionRequiredDialog({
|
||||
reason: options?.isInsufficientCredits
|
||||
? 'out_of_credits'
|
||||
|
||||
Reference in New Issue
Block a user