Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
8dfb024df0 test: cover renamed canAccessSubscriptionFeatures call sites
Add unit/component tests for code paths the rename brought into the diff
that were previously untested on main: useLegacyBilling subscription
status, cloudRemoteConfig watcher, useCoreCommands queue guards,
useAuthActions purchaseCredits guard, useSettingUI plan-credits panel,
LinearControls, and CurrentUserPopoverWorkspace plan actions.
2026-06-29 20:32:53 -07:00
bymyself
d93666d160 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'.

Pure identifier rename, no behavioral changes. Also renames the internal
alias isSubscribedOrIsNotCloud to match.
2026-06-29 20:32:53 -07:00
50 changed files with 1033 additions and 412 deletions

View File

@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
const mockCanAccessSubscriptionFeatures = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures
})
}))
@@ -32,7 +32,7 @@ function renderWrapper() {
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
mockCanAccessSubscriptionFeatures.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
mockCanAccessSubscriptionFeatures.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
mockCanAccessSubscriptionFeatures.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
mockCanAccessSubscriptionFeatures.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).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

@@ -64,7 +64,7 @@ function makeSubscription(
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockCanAccessSubscriptionFeatures = ref(true)
const mockIsFreeTier = ref(false)
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
@@ -73,7 +73,7 @@ const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
isFreeTier: mockIsFreeTier,
tier: mockTier,
subscription: mockSubscription,
@@ -153,7 +153,7 @@ describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockCanAccessSubscriptionFeatures.value = true
mockIsFreeTier.value = false
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
@@ -203,7 +203,7 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
mockCanAccessSubscriptionFeatures.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
@@ -478,7 +478,7 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
mockCanAccessSubscriptionFeatures.value = false
renderComponent()
expect(
screen.queryByTestId('subscribe-button-mock')

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="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
@@ -80,7 +83,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"
@@ -110,7 +113,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"
@@ -178,7 +181,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier,
tier,
subscription,

View File

@@ -1,4 +1,3 @@
import { FirebaseError } from 'firebase/app'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -8,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(() => ({
@@ -27,28 +29,21 @@ const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
])
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key,
st: (key: string, fallback: string) => {
const code = key.replace('auth.errors.', '')
return knownAuthErrorCodes.has(code) ? key : fallback
}
values?.workflow ? `${key}:${values.workflow}` : key
}))
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', () => ({
@@ -71,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' }
}))
@@ -84,7 +83,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: mockToastErrorHandler
toastErrorHandler: vi.fn()
})
}))
@@ -206,79 +205,39 @@ describe('useAuthActions.logout', () => {
})
})
describe('useAuthActions.reportError', () => {
describe('useAuthActions.purchaseCredits', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockCanAccessSubscriptionFeatures.value = false
})
it('shows the friendly message for a known Firebase auth code', () => {
const { reportError } = useAuthActions()
it('returns early when canAccessSubscriptionFeatures is false', async () => {
mockCanAccessSubscriptionFeatures.value = false
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
const { purchaseCredits } = useAuthActions()
await purchaseCredits(10)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.auth/invalid-credential'
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(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('shows the signupBlocked message when the error carries the signup_blocked token', () => {
const { reportError } = useAuthActions()
// The backend wraps the rejection in a generic code; we match the token in
// the message, so it must win over the auth.errors.${code} fallback.
reportError(
new FirebaseError(
'auth/internal-error',
'Account creation is temporarily unavailable. (ref: signup_blocked)'
)
expect(mockStartTopupTracking).toHaveBeenCalled()
expect(mockOpen).toHaveBeenCalledWith(
'https://checkout.example.com',
'_blank'
)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.signupBlocked'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('matches the signup_blocked token case-insensitively', () => {
const { reportError } = useAuthActions()
reportError(
new FirebaseError('auth/internal-error', 'rejected: SIGNUP_BLOCKED')
)
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.signupBlocked'
})
})
it('shows the generic fallback for an unknown Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.generic'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('delegates non-Firebase errors to toastErrorHandler', () => {
const { reportError } = useAuthActions()
const networkError = new TypeError('Failed to fetch')
reportError(networkError)
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
mockOpen.mockRestore()
})
})

View File

@@ -131,8 +131,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

@@ -92,7 +92,7 @@ export interface BillingState {
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
canAccessSubscriptionFeatures: ComputedRef<boolean>
/** Reflects the active workspace's tier, not the user's personal tier. */
isFreeTier: ComputedRef<boolean>
/** Coarse funding state (`billing_status`); legacy reports null. */

View File

@@ -84,7 +84,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' },
subscriptionStatus: {
@@ -245,9 +245,9 @@ describe('useBillingContext', () => {
await expect(topup(99.5)).rejects.toThrow()
})
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

@@ -132,8 +132,8 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.currentTeamCreditStop)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
const canAccessSubscriptionFeatures = computed(() =>
toValue(activeContext.value.canAccessSubscriptionFeatures)
)
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
@@ -141,7 +141,7 @@ function useBillingContextInternal(): BillingContext {
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
isActiveSubscription.value &&
canAccessSubscriptionFeatures.value &&
!isFreeTier.value &&
currentTeamCreditStop.value === null &&
(currentPlanSlug.value
@@ -296,7 +296,7 @@ function useBillingContextInternal(): BillingContext {
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier,
isLegacyTeamPlan,
billingStatus,

View File

@@ -0,0 +1,340 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLegacyBilling } from './useLegacyBilling'
const {
mockCanAccessSubscriptionFeatures,
mockSubscriptionTier,
mockSubscriptionDuration,
mockFormattedRenewalDate,
mockFormattedEndDate,
mockIsCancelled,
mockSubscriptionStatus,
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 },
mockSubscriptionStatus: {
value: { renewal_date: 'Jan 1, 2025', end_date: null } as {
renewal_date?: string | null
end_date?: string | null
} | null
},
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,
subscriptionStatus: mockSubscriptionStatus,
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
mockSubscriptionStatus.value = {
renewal_date: 'Jan 1, 2025',
end_date: null
}
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
mockSubscriptionStatus.value = {
renewal_date: 'Jan 1, 2025',
end_date: '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

@@ -26,7 +26,7 @@ import type {
*/
export function useLegacyBilling(): BillingState & BillingActions {
const {
isActiveSubscription: legacyIsActiveSubscription,
canAccessSubscriptionFeatures: legacyCanAccessSubscriptionFeatures,
subscriptionTier,
subscriptionDuration,
subscriptionStatus: legacySubscriptionStatus,
@@ -44,16 +44,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
@@ -84,7 +86,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
const billingStatus = computed<BillingStatus | null>(() => null)
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
if (isCancelled.value) return 'canceled'
if (legacyIsActiveSubscription.value) return 'active'
if (legacyCanAccessSubscriptionFeatures.value) return 'active'
return null
})
const tier = computed(() => subscriptionTier.value)
@@ -188,7 +190,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
if (!canAccessSubscriptionFeatures.value) {
legacyShowSubscriptionDialog()
}
}
@@ -208,7 +210,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier,
billingStatus,
subscriptionStatus,

View File

@@ -5,13 +5,11 @@ import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useExternalLink } from '@/composables/useExternalLink'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type * as ModelStoreModule from '@/stores/modelStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { fromPartial } from '@total-typescript/shoehorn'
// Mock vue-i18n for useExternalLink
const mockLocale = ref('en')
@@ -55,6 +53,7 @@ vi.mock('@/scripts/app', () => {
}),
openClipspace: vi.fn(),
refreshComboInNodes: vi.fn().mockResolvedValue(undefined),
queuePrompt: vi.fn().mockResolvedValue(undefined),
canvas: mockCanvas,
rootGraph: {
clear: mockGraphClear
@@ -116,7 +115,9 @@ vi.mock('@/services/litegraphService', () => ({
const mockTrackHelpResourceClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackHelpResourceClicked: mockTrackHelpResourceClicked
trackHelpResourceClicked: mockTrackHelpResourceClicked,
trackRunButton: vi.fn(),
trackWorkflowExecution: vi.fn()
}))
}))
@@ -137,23 +138,6 @@ vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({}))
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
}))
const mockAssetBrowse = vi.hoisted(() =>
vi.fn<(options: { onAssetSelected?: (asset: AssetItem) => void }) => void>()
)
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
useAssetBrowserDialog: vi.fn(() => ({ browse: mockAssetBrowse }))
}))
const mockStartModelNodeDrag = vi.hoisted(() => vi.fn())
vi.mock('@/composables/node/startModelNodeDragFromAsset', () => ({
startModelNodeDragFromAsset: mockStartModelNodeDrag
}))
const mockChangeTracker = vi.hoisted(() => ({
captureCanvasState: vi.fn()
}))
@@ -190,15 +174,21 @@ 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, mockShowSubscriptionDialog } =
vi.hoisted(() => ({
mockCanAccessSubscriptionFeatures: { value: true },
mockShowSubscriptionDialog: vi.fn()
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: true },
showSubscriptionDialog: vi.fn()
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
showSubscriptionDialog: mockShowSubscriptionDialog
}))
}))
@@ -298,6 +288,7 @@ describe('useCoreCommands', () => {
// Reset app state
app.canvas.subgraph = undefined
mockCanAccessSubscriptionFeatures.value = true
// Mock settings store
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
@@ -638,46 +629,52 @@ describe('useCoreCommands', () => {
})
})
describe('BrowseModelAssets command', () => {
const asset = fromPartial<AssetItem>({ id: 'asset-1' })
describe('Queue commands with subscription check', () => {
const findCmd = (id: string) =>
useCoreCommands().find((cmd) => cmd.id === id)!
async function selectAssetFromBrowser() {
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
it('Comfy.QueuePrompt shows subscription dialog and skips queue when features inaccessible', async () => {
mockCanAccessSubscriptionFeatures.value = false
const command = useCoreCommands().find(
(cmd) => cmd.id === 'Comfy.BrowseModelAssets'
)!
await command.function()
await findCmd('Comfy.QueuePrompt').function()
const { onAssetSelected } = mockAssetBrowse.mock.calls[0][0]
onAssetSelected?.(asset)
}
it('starts a model node drag for the selected asset', async () => {
mockStartModelNodeDrag.mockReturnValue(undefined)
await selectAssetFromBrowser()
expect(mockStartModelNodeDrag).toHaveBeenCalledWith(
asset,
'asset_browser'
)
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
expect(app.queuePrompt).not.toHaveBeenCalled()
})
it('shows an error toast when the asset cannot start a drag', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockStartModelNodeDrag.mockReturnValue({
code: 'NO_PROVIDER',
message: 'No node provider registered',
assetId: 'asset-1'
})
it('Comfy.QueuePrompt queues prompt when features accessible', async () => {
mockCanAccessSubscriptionFeatures.value = true
await selectAssetFromBrowser()
await findCmd('Comfy.QueuePrompt').function()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(mockShowSubscriptionDialog).not.toHaveBeenCalled()
expect(app.queuePrompt).toHaveBeenCalledWith(0, expect.any(Number))
})
it('Comfy.QueuePromptFront shows subscription dialog and skips queue when features inaccessible', async () => {
mockCanAccessSubscriptionFeatures.value = false
await findCmd('Comfy.QueuePromptFront').function()
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
expect(app.queuePrompt).not.toHaveBeenCalled()
})
it('Comfy.QueuePromptFront queues prompt at front when features accessible', async () => {
mockCanAccessSubscriptionFeatures.value = true
await findCmd('Comfy.QueuePromptFront').function()
expect(mockShowSubscriptionDialog).not.toHaveBeenCalled()
expect(app.queuePrompt).toHaveBeenCalledWith(-1, expect.any(Number))
})
it('Comfy.QueueSelectedOutputNodes shows subscription dialog when features inaccessible', async () => {
mockCanAccessSubscriptionFeatures.value = false
await findCmd('Comfy.QueueSelectedOutputNodes').function()
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
})
})
})

View File

@@ -74,7 +74,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()
@@ -502,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
trigger_source?: ExecutionTriggerSource
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
if (!canAccessSubscriptionFeatures.value) {
showSubscriptionDialog()
return
}
@@ -525,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
trigger_source?: ExecutionTriggerSource
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
if (!canAccessSubscriptionFeatures.value) {
showSubscriptionDialog()
return
}
@@ -547,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
trigger_source?: ExecutionTriggerSource
}) => {
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 }
}))
@@ -111,7 +111,7 @@ describe('CloudSubscriptionRedirectView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQuery = {}
subscriptionMocks.isActiveSubscription.value = false
subscriptionMocks.canAccessSubscriptionFeatures.value = false
subscriptionMocks.isInitialized.value = true
})
@@ -150,7 +150,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

@@ -29,7 +29,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)
@@ -109,7 +110,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
await initialize()
}
if (isActiveSubscription.value) {
if (canAccessSubscriptionFeatures.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)

View File

@@ -20,7 +20,7 @@ type TeamStop = CurrentTeamCreditStop
const state = vi.hoisted(() => ({
balance: null as Balance | null,
subscription: null as Subscription | null,
isActiveSubscription: false,
canAccessSubscriptionFeatures: false,
isFreeTier: false,
currentTeamCreditStop: null as TeamStop | null,
isLoading: false,
@@ -53,7 +53,9 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => state.balance),
subscription: computed(() => state.subscription),
isActiveSubscription: computed(() => state.isActiveSubscription),
canAccessSubscriptionFeatures: computed(
() => state.canAccessSubscriptionFeatures
),
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
isLoading: computed(() => state.isLoading),
@@ -142,7 +144,7 @@ function renderTile(props: Record<string, unknown> = {}) {
}
function activeProSubscription() {
state.isActiveSubscription = true
state.canAccessSubscriptionFeatures = true
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
@@ -160,7 +162,7 @@ describe('CreditsTile', () => {
beforeEach(() => {
state.balance = null
state.subscription = null
state.isActiveSubscription = false
state.canAccessSubscriptionFeatures = false
state.isFreeTier = false
state.currentTeamCreditStop = null
state.isLoading = false
@@ -195,7 +197,7 @@ describe('CreditsTile', () => {
})
it('uses the team credit stop monthly grant for the monthly total', () => {
state.isActiveSubscription = true
state.canAccessSubscriptionFeatures = true
state.subscription = {
tier: 'TEAM',
duration: 'ANNUAL',
@@ -214,7 +216,7 @@ describe('CreditsTile', () => {
})
it('uses the per-month nominal grant for an annual personal tier', () => {
state.isActiveSubscription = true
state.canAccessSubscriptionFeatures = true
state.subscription = {
tier: 'PRO',
duration: 'ANNUAL',
@@ -262,7 +264,7 @@ describe('CreditsTile', () => {
})
it('shows only the balance with no breakdown when there is no active subscription', () => {
state.isActiveSubscription = false
state.canAccessSubscriptionFeatures = false
state.balance = { amountMicros: 500 }
const { container } = renderTile()
expect(container.textContent).toContain('1,055')

View File

@@ -206,7 +206,7 @@ const { locale, t } = useI18n()
const {
subscription,
balance,
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier,
currentTeamCreditStop,
fetchBalance,
@@ -299,7 +299,9 @@ const monthlyUsageLabel = computed(() =>
})
)
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
const showBreakdown = computed(
() => canAccessSubscriptionFeatures.value && !zeroState
)
const showBar = computed(
() =>
showBreakdown.value &&
@@ -307,7 +309,10 @@ const showBar = computed(
monthlyTotalCredits.value > 0
)
const showActionButton = computed(
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
() =>
canAccessSubscriptionFeatures.value &&
!zeroState &&
permissions.value.canTopUp
)
const isMonthlyDepleted = computed(

View File

@@ -23,7 +23,7 @@ function createDeferredPromise<T>() {
return { promise, resolve }
}
const mockIsActiveSubscription = ref(false)
const mockCanAccessSubscriptionFeatures = ref(false)
const mockSubscriptionTier = ref<SubscriptionTier | null>(null)
const mockSubscriptionDuration = ref<'MONTHLY' | 'ANNUAL'>('MONTHLY')
const mockAccessBillingPortal = vi.fn()
@@ -66,13 +66,15 @@ Object.defineProperty(globalThis, 'localStorage', {
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
canAccessSubscriptionFeatures: computed(
() => mockCanAccessSubscriptionFeatures.value
),
isFreeTier: computed(() => mockSubscriptionTier.value === 'FREE'),
tier: computed(() => mockSubscriptionTier.value),
subscription: computed(() =>
mockSubscriptionTier.value
? {
isActive: mockIsActiveSubscription.value,
isActive: mockCanAccessSubscriptionFeatures.value,
tier: mockSubscriptionTier.value,
duration: mockSubscriptionDuration.value,
planSlug: null,
@@ -226,7 +228,7 @@ const onChooseTeamWorkspace = vi.fn()
describe('PricingTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockCanAccessSubscriptionFeatures.value = false
mockSubscriptionTier.value = null
mockSubscriptionDuration.value = 'MONTHLY'
mockUserId.value = 'user-123'
@@ -242,7 +244,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()
@@ -267,7 +269,7 @@ describe('PricingTable', () => {
})
it('should call accessBillingPortal with different tiers correctly', async () => {
mockIsActiveSubscription.value = true
mockCanAccessSubscriptionFeatures.value = true
mockSubscriptionTier.value = 'STANDARD'
renderComponent()
@@ -284,7 +286,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>()
@@ -324,7 +326,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)
@@ -344,7 +346,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'
@@ -371,7 +373,7 @@ describe('PricingTable', () => {
})
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockCanAccessSubscriptionFeatures.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'ANNUAL'
@@ -391,7 +393,7 @@ describe('PricingTable', () => {
})
it('does not highlight a current plan when the facade duration differs from the selected cycle', async () => {
mockIsActiveSubscription.value = true
mockCanAccessSubscriptionFeatures.value = true
mockSubscriptionTier.value = 'CREATOR'
mockSubscriptionDuration.value = 'MONTHLY'
@@ -406,7 +408,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')
@@ -436,7 +438,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

@@ -362,7 +362,7 @@ const tiers: PricingTierConfig[] = [
}
]
const {
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier,
tier: subscriptionTier,
subscription
@@ -382,7 +382,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

@@ -37,12 +37,12 @@ const emit = defineEmits<{
subscribed: []
}>()
const { isActiveSubscription, showSubscriptionDialog, tier } =
const { canAccessSubscriptionFeatures, showSubscriptionDialog, tier } =
useBillingContext()
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()
})
}))
@@ -240,7 +244,7 @@ describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock state
mockIsActiveSubscription.value = false
mockCanAccessSubscriptionFeatures.value = false
mockIsCancelled.value = false
mockSubscriptionTier.value = 'CREATOR'
mockIsYearlySubscription.value = false
@@ -250,13 +254,13 @@ 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')
})
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()
@@ -264,14 +268,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')

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')
}}
@@ -48,5 +48,5 @@ const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { isActiveSubscription } = useBillingContext()
const { canAccessSubscriptionFeatures } = useBillingContext()
</script>

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"
@@ -137,7 +137,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

@@ -46,7 +46,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
@@ -225,7 +225,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] }
: {}),
@@ -258,7 +258,7 @@ function useSubscriptionInternal() {
const { startCancellationWatcher, stopCancellationWatcher } =
useSubscriptionCancellationWatcher({
fetchStatus,
isActiveSubscription: isSubscribedOrIsNotCloud,
canAccessSubscriptionFeatures: canAccessSubscriptionFeatures,
subscriptionStatus,
telemetry,
shouldWatchCancellation: isSubscriptionEnabled
@@ -276,7 +276,7 @@ function useSubscriptionInternal() {
const requireActiveSubscription = async (): Promise<void> => {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
if (!canAccessSubscriptionFeatures.value) {
showSubscriptionDialog()
}
}
@@ -442,7 +442,7 @@ function useSubscriptionInternal() {
return {
// State
isActiveSubscription: isSubscribedOrIsNotCloud,
canAccessSubscriptionFeatures: 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

@@ -38,7 +38,7 @@ vi.mock('@/platform/telemetry', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
canAccessSubscriptionFeatures: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})

View File

@@ -1,161 +1,204 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, ref } from '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'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverWorkspace from './CurrentUserPopoverWorkspace.vue'
const showCreateWorkspaceDialog = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userDisplayName: ref('Liz'),
userEmail: ref('liz@example.com'),
userPhotoUrl: ref(null),
handleSignOut: vi.fn()
})
}))
// 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: () => ({
isActiveSubscription: ref(true),
isFreeTier: ref(false),
subscription: ref(null),
balance: ref(null),
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: computed(() => ({
canTopUp: false,
canManageSubscription: false
}))
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() })
useSubscriptionDialog: () => ({
showPricingTable: vi.fn()
})
})
)
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: () => ({ show: vi.fn() })
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showCreateWorkspaceDialog,
showTopUpCreditsDialog: vi.fn()
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
buildDocsUrl: vi.fn().mockReturnValue('https://docs.example.com'),
docsPaths: { partnerNodesPricing: '/pricing' }
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => undefined
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
buildDocsUrl: vi.fn(() => 'https://docs.comfy.org'),
docsPaths: { partnerNodesPricing: 'partner-nodes' }
useTelemetry: () => ({
trackAddApiCreditButtonClicked: vi.fn()
})
}))
const WorkspaceSwitcherPopoverStub = defineComponent({
emits: ['select', 'create'],
template: `
<div>
<button data-testid="stub-select-workspace" @click="$emit('select')" />
<button data-testid="stub-create-workspace" @click="$emit('create')" />
</div>
`
})
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn().mockReturnValue('$10.00')
}))
function renderComponent() {
return render(CurrentUserPopoverWorkspace, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
teamWorkspace: {
initState: 'ready',
activeWorkspaceId: 'ws-personal'
}
}
}),
i18n
],
directives: {
tooltip: {}
},
stubs: {
WorkspaceSwitcherPopover: WorkspaceSwitcherPopoverStub,
SubscribeButton: true,
UserAvatar: true,
WorkspaceProfilePic: true,
Skeleton: true,
Divider: true
}
}
})
}
// 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', () => {
it('toggles the workspace switcher panel from the selector row', async () => {
const user = userEvent.setup()
renderComponent()
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
await user.click(screen.getByTestId('workspace-switcher-trigger'))
expect(screen.getByTestId('workspace-switcher-panel')).toBeInTheDocument()
await user.click(screen.getByTestId('workspace-switcher-trigger'))
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
beforeEach(() => {
vi.clearAllMocks()
mockCanAccessSubscriptionFeatures.value = true
mockIsFreeTier.value = false
mockSubscription.value = { isCancelled: false }
})
it('closes the switcher panel after selecting a workspace', async () => {
const user = userEvent.setup()
renderComponent()
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
await user.click(screen.getByTestId('workspace-switcher-trigger'))
await user.click(screen.getByTestId('stub-select-workspace'))
return render(CurrentUserPopoverWorkspace, {
global: {
plugins: [i18n],
directives: {
tooltip: {}
}
}
})
}
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
})
describe('canAccessSubscriptionFeatures', () => {
it('shows add credits button when canAccessSubscriptionFeatures is true and not free tier', () => {
mockCanAccessSubscriptionFeatures.value = true
mockIsFreeTier.value = false
renderComponent()
it('opens the create-workspace dialog and closes the popover on create', async () => {
const user = userEvent.setup()
const { emitted } = renderComponent()
expect(screen.getByTestId('add-credits-button')).toBeInTheDocument()
expect(
screen.queryByTestId('upgrade-to-add-credits-button')
).not.toBeInTheDocument()
})
await user.click(screen.getByTestId('workspace-switcher-trigger'))
await user.click(screen.getByTestId('stub-create-workspace'))
it('shows upgrade button when canAccessSubscriptionFeatures is true and is free tier', () => {
mockCanAccessSubscriptionFeatures.value = true
mockIsFreeTier.value = true
renderComponent()
expect(showCreateWorkspaceDialog).toHaveBeenCalled()
expect(emitted('close')).toHaveLength(1)
expect(
screen.queryByTestId('workspace-switcher-panel')
).not.toBeInTheDocument()
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

@@ -81,7 +81,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"
@@ -91,7 +93,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"
@@ -268,7 +270,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier,
subscription,
balance,
@@ -302,12 +304,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

@@ -126,7 +126,9 @@ const mockError = ref<string | null>(null)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
canAccessSubscriptionFeatures: computed(
() => mockIsActiveSubscription.value
),
isFreeTier: computed(() => false),
subscription: mockSubscription,
teamCreditStops: mockTeamCreditStops,
@@ -180,7 +182,9 @@ vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
})),
uiConfig: computed(() => mockUiConfig.value),
isInPersonalWorkspace: mockIsInPersonalWorkspace,
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
canAccessSubscriptionFeatures: computed(
() => mockIsActiveSubscription.value
),
isOriginalOwner: mockIsOriginalOwner,
isTeamPlanCancelled: mockIsTeamPlanCancelled,
isDeleteDisabled: mockIsDeleteDisabled,

View File

@@ -161,7 +161,7 @@
<span class="text-base">{{ priceUnitLabel }}</span>
</div>
<div
v-if="isActiveSubscription"
v-if="canAccessSubscriptionFeatures"
class="text-sm text-text-secondary"
>
<template v-if="isTeamPlanCancelled">
@@ -182,7 +182,7 @@
</div>
<div
v-if="isActiveSubscription"
v-if="canAccessSubscriptionFeatures"
class="flex flex-wrap gap-2 md:ml-auto"
>
<Button
@@ -246,7 +246,7 @@
</div>
<div
v-if="isActiveSubscription || isPersonalFree"
v-if="canAccessSubscriptionFeatures || isPersonalFree"
class="flex flex-col gap-2"
>
<i18n-t
@@ -342,7 +342,7 @@ const billingOperationStore = useBillingOperationStore()
const isSettingUp = computed(() => billingOperationStore.isSettingUp)
const {
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier: isFreeTierPlan,
subscription,
isLoading,
@@ -363,7 +363,7 @@ const { menuEntries } = useWorkspaceMenuItems()
const showSubscribePrompt = computed(() => {
if (!permissions.value.canManageSubscription) return false
if (isTeamPlanCancelled.value) return false
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
if (isInPersonalWorkspace.value) return !canAccessSubscriptionFeatures.value
return !isWorkspaceSubscribed.value
})
@@ -376,13 +376,13 @@ const isPersonalFree = computed(
)
const isTeamActive = computed(
() => !isInPersonalWorkspace.value && isActiveSubscription.value
() => !isInPersonalWorkspace.value && canAccessSubscriptionFeatures.value
)
const isMemberView = computed(
() =>
!permissions.value.canManageSubscription &&
!isActiveSubscription.value &&
!canAccessSubscriptionFeatures.value &&
!isWorkspaceSubscribed.value
)

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')
}}
@@ -52,7 +52,7 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription } = useBillingContext()
const { canAccessSubscriptionFeatures } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function onDismiss() {

View File

@@ -265,7 +265,7 @@ const {
mockIsInviteLimitReached,
mockPermissions,
mockUiConfig,
mockIsActiveSubscription,
mockCanAccessSubscriptionFeatures,
mockSubscription
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
@@ -302,7 +302,7 @@ const {
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
workspaceMenuDisabledTooltip: null as string | null
}),
mockIsActiveSubscription: ref(true),
mockCanAccessSubscriptionFeatures: ref(true),
mockSubscription: ref<{ tier: string; isCancelled?: boolean } | null>({
tier: 'PRO',
isCancelled: false
@@ -364,7 +364,7 @@ vi.mock(
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription,
canAccessSubscriptionFeatures: mockCanAccessSubscriptionFeatures,
subscription: mockSubscription,
getMaxSeats: (tierKey: string) => {
const seats: Record<string, number> = {
@@ -405,7 +405,7 @@ describe('useMembersPanel', () => {
mockIsWorkspaceSubscribed.value = true
mockTotalMemberSlots.value = 0
mockIsInviteLimitReached.value = false
mockIsActiveSubscription.value = true
mockCanAccessSubscriptionFeatures.value = true
mockSubscription.value = { tier: 'PRO', isCancelled: false }
})

View File

@@ -167,7 +167,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)
})
@@ -191,7 +191,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)
})
@@ -205,7 +205,7 @@ describe('useWorkspaceBilling', () => {
await billing.fetchStatus()
expect(billing.subscription.value?.isCancelled).toBe(false)
expect(billing.isActiveSubscription.value).toBe(true)
expect(billing.canAccessSubscriptionFeatures.value).toBe(true)
})
it('reports free tier when status tier is FREE', async () => {

View File

@@ -37,7 +37,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(
@@ -274,7 +274,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
if (!canAccessSubscriptionFeatures.value) {
subscriptionDialog.show()
}
}
@@ -294,7 +294,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,
canAccessSubscriptionFeatures,
isFreeTier,
billingStatus,
subscriptionStatus,

View File

@@ -19,7 +19,7 @@ export function useWorkspaceMenuItems() {
const {
uiConfig,
isInPersonalWorkspace,
isActiveSubscription,
canAccessSubscriptionFeatures,
isOriginalOwner,
isTeamPlanCancelled,
isDeleteDisabled,
@@ -51,7 +51,7 @@ export function useWorkspaceMenuItems() {
const canCancelPlan = computed(
() =>
isOriginalOwner.value &&
isActiveSubscription.value &&
canAccessSubscriptionFeatures.value &&
!isTeamPlanCancelled.value &&
!isFreeTier.value
)
@@ -59,7 +59,7 @@ export function useWorkspaceMenuItems() {
const canDeleteWorkspace = computed(
() =>
isOriginalOwner.value &&
(!isInPersonalWorkspace.value || isActiveSubscription.value)
(!isInPersonalWorkspace.value || canAccessSubscriptionFeatures.value)
)
const canLeaveWorkspace = computed(

View File

@@ -41,7 +41,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: ref(mockIsActiveSubscription.value),
canAccessSubscriptionFeatures: ref(mockIsActiveSubscription.value),
subscription: ref({ isCancelled: mockIsCancelled.value })
})
}))

View File

@@ -162,7 +162,7 @@ function isOriginalOwnerByEmail(
function useWorkspaceUIInternal() {
const store = useTeamWorkspaceStore()
const { userEmail } = useCurrentUser()
const { isActiveSubscription, subscription } = useBillingContext()
const { canAccessSubscriptionFeatures, subscription } = useBillingContext()
const isInPersonalWorkspace = computed(() => store.isInPersonalWorkspace)
const isWorkspaceSubscribed = computed(() => store.isWorkspaceSubscribed)
@@ -222,7 +222,8 @@ function useWorkspaceUIInternal() {
// their menus can't desync on a billing-flag change.
const isDeleteDisabled = computed(
() =>
isActiveSubscription.value && !(subscription.value?.isCancelled ?? false)
canAccessSubscriptionFeatures.value &&
!(subscription.value?.isCancelled ?? false)
)
const deleteDisabledTooltipKey = computed(() =>
@@ -237,7 +238,7 @@ function useWorkspaceUIInternal() {
workspaceRole,
isInPersonalWorkspace,
isWorkspaceSubscribed,
isActiveSubscription,
canAccessSubscriptionFeatures,
isOriginalOwner,
isTeamPlanCancelled,
isDeleteDisabled,

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()
@@ -138,7 +138,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">
@@ -190,7 +190,7 @@ function handleDragDrop() {
class="h-7 min-w-40"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
v-if="!canAccessSubscriptionFeatures"
class="mt-4 w-full"
/>
<Button

View File

@@ -29,7 +29,7 @@ vi.mock('@/platform/distribution/types', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
canAccessSubscriptionFeatures: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})

View File

@@ -25,7 +25,7 @@ vi.mock('@/platform/distribution/types', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
canAccessSubscriptionFeatures: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})

View File

@@ -329,8 +329,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'

View File

@@ -26,7 +26,7 @@ export function useBillingContext(): BillingContext {
currentTeamCreditStop: computed(() => null),
isLoading: ref(false),
error: ref<string | null>(null),
isActiveSubscription: computed(() => false),
canAccessSubscriptionFeatures: computed(() => false),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
billingStatus: computed(() => null),