Compare commits

...

11 Commits

Author SHA1 Message Date
bymyself
5c266aa8d6 test: add tests for canAccessSubscriptionFeatures coverage
Add tests for Vue components and services that use canAccessSubscriptionFeatures:
- SubscribeButton: rendering and click behavior tests
- InviteMemberUpsellDialogContent: display changes based on subscription status
- Update existing tests with improved pinia mock pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 16:36:43 -07:00
bymyself
6133314930 test: add Vue component tests for canAccessSubscriptionFeatures
Add tests for:
- LinearControls.vue (4 tests)
- CloudRunButtonWrapper.vue (2 tests)
- WorkspacePanelContent.vue (2 tests)
- CurrentUserPopoverWorkspace.vue (4 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 16:26:58 -07:00
bymyself
18ef64ba19 test: add CurrentUserPopoverLegacy tests for canAccessSubscriptionFeatures
Tests verify UI elements are hidden when canAccessSubscriptionFeatures
is false: credits section, partner nodes menu, and manage plan menu.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 15:54:06 -07:00
bymyself
7d8b21cb10 test: add dialogService tests for canAccessSubscriptionFeatures branch
Cover the branch where showTopUpCreditsDialog redirects to
subscription dialog when canAccessSubscriptionFeatures is false
or isFreeTier is true.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 15:51:25 -07:00
bymyself
572aac3e39 test: add queue command tests for canAccessSubscriptionFeatures
- Add tests for Comfy.QueuePrompt subscription check
- Add tests for Comfy.QueuePromptFront subscription check
- Add tests for Comfy.QueueSelectedOutputNodes subscription check
- Verify subscription dialog shown when features not accessible

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 15:37:34 -07:00
bymyself
0a06ccae75 test: add purchaseCredits tests using canAccessSubscriptionFeatures
- Update mock from isActiveSubscription to canAccessSubscriptionFeatures
- Add tests for purchaseCredits early return and successful flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 15:35:30 -07:00
bymyself
1b7e873fe2 test: add tests for dialogService and cloudRemoteConfig
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11464#pullrequestreview-2944831949
2026-05-14 15:35:30 -07:00
bymyself
b7f9c3e3f4 test: add unit tests for nodeDefSchema
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11464#pullrequestreview-2944831949
2026-05-14 15:35:30 -07:00
bymyself
5993297f1a test: add unit tests for useLegacyBilling composable
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11464#pullrequestreview-2944831949
2026-05-14 15:35:30 -07:00
Connor Byrne
c8cee3390a fix: update missed mock property rename in MembersPanelContent test 2026-05-14 15:35:30 -07:00
Glary-Bot
df04be1759 refactor: rename isActiveSubscription to canAccessSubscriptionFeatures
The name isActiveSubscription was misleading: it's defined as
  !isCloud || !subscription_required || subscription.is_active
so it returns true on non-cloud builds and when subscription is not
required, regardless of actual subscription state. The new name
canAccessSubscriptionFeatures reads correctly at every call site:
'if can access features, show UI / allow action'.

This revives the spirit of PR #7127 which attempted the internal
rename (isSubscribed -> isSubscribedOrIsNotCloud) but never tackled
the exported public name.

Scope:
- Renamed exported composable property from isActiveSubscription to
  canAccessSubscriptionFeatures in useSubscription, useBillingContext,
  useWorkspaceBilling, useLegacyBilling.
- Renamed the internal alias isSubscribedOrIsNotCloud to match.
- Updated all 112 call sites across 37 files (templates, composables,
  services, tests, prop names in kebab-case).

No behavioral changes. Pure identifier rename.
2026-05-14 15:35:30 -07:00
51 changed files with 1952 additions and 150 deletions

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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()
})
})

View File

@@ -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),

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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,

View 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()
})
})
})

View File

@@ -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

View File

@@ -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()
})
})
})

View File

@@ -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
}

View 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()
})
})

View File

@@ -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()

View File

@@ -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' })

View File

@@ -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(

View File

@@ -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()

View File

@@ -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>(() =>

View File

@@ -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()
})
})
})

View File

@@ -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')

View File

@@ -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}\)/

View File

@@ -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()

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
})
})

View File

@@ -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,

View File

@@ -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

View File

@@ -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 {

View File

@@ -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', () => ({

View File

@@ -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 = {

View File

@@ -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()
})
})
})

View File

@@ -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 = () => {

View File

@@ -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
)

View File

@@ -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()
})
})
})

View File

@@ -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' })

View File

@@ -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<{

View File

@@ -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',

View File

@@ -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,

View File

@@ -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()
})
})
})

View File

@@ -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' &&

View File

@@ -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)
})

View File

@@ -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,

View File

@@ -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)
})

View File

@@ -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

View 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()
})
})
})

View File

@@ -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

View 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)')
})
})
})

View 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'
})
})
})
})

View File

@@ -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'