From a7b284c73efc49e2e0eefda8d8bbc0421f520af6 Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Thu, 11 Jun 2026 13:55:00 +0900 Subject: [PATCH 1/2] feat(billing): role-aware run-lock for cancelled/inactive team plans (FE-978) --- .../CloudRunButtonWrapper.test.ts | 69 ++++++++ src/locales/en/main.json | 6 + .../components/SubscribeToRun.test.ts | 114 +++++++++++++ .../components/SubscribeToRun.vue | 23 ++- ...tionRequiredDialogContentWorkspace.test.ts | 66 +++++++- ...criptionRequiredDialogContentWorkspace.vue | 150 +++++++++++------- 6 files changed, 360 insertions(+), 68 deletions(-) create mode 100644 src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts create mode 100644 src/platform/cloud/subscription/components/SubscribeToRun.test.ts diff --git a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts new file mode 100644 index 00000000000..b1e3d3dfb8a --- /dev/null +++ b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue' + +const mockIsActiveSubscription = ref(true) + +vi.mock('@/composables/billing/useBillingContext', () => ({ + useBillingContext: () => ({ + isActiveSubscription: mockIsActiveSubscription + }) +})) + +vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({ + default: { + name: 'ComfyQueueButton', + template: '
' + } +})) + +vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({ + default: { + name: 'SubscribeToRun', + template: '
' + } +})) + +function renderWrapper() { + return render(CloudRunButtonWrapper) +} + +describe('CloudRunButtonWrapper', () => { + beforeEach(() => { + mockIsActiveSubscription.value = true + }) + + it('renders the runnable queue button when the subscription is active', () => { + renderWrapper() + + expect(screen.getByTestId('queue-button')).toBeInTheDocument() + expect( + screen.queryByTestId('subscribe-to-run-button') + ).not.toBeInTheDocument() + }) + + it('locks the run button when the subscription is inactive', () => { + mockIsActiveSubscription.value = false + renderWrapper() + + expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument() + expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument() + }) + + it('unlocks the run button once the subscription becomes active again', async () => { + mockIsActiveSubscription.value = false + renderWrapper() + + expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument() + + mockIsActiveSubscription.value = true + await nextTick() + + expect(screen.getByTestId('queue-button')).toBeInTheDocument() + expect( + screen.queryByTestId('subscribe-to-run-button') + ).not.toBeInTheDocument() + }) +}) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 405510871d3..bde34b592d4 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2507,6 +2507,12 @@ "pollingFailed": "Subscription activation failed", "pollingTimeout": "Timed out waiting for subscription. Please refresh and try again." }, + "inactive": { + "memberTitle": "This workspace is paused", + "memberDescription": "Contact your workspace owner to resubscribe and continue running workflows.", + "memberRunTooltip": "Contact your workspace owner to resubscribe", + "runLabel": "Run" + }, "subscribeToRun": "Subscribe", "subscribeToRunFull": "Subscribe to Run", "subscribeForMore": "Upgrade", diff --git a/src/platform/cloud/subscription/components/SubscribeToRun.test.ts b/src/platform/cloud/subscription/components/SubscribeToRun.test.ts new file mode 100644 index 00000000000..c0770774608 --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscribeToRun.test.ts @@ -0,0 +1,114 @@ +import type * as VueUseCore from '@vueuse/core' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import SubscribeToRun from './SubscribeToRun.vue' + +const mockShowSubscriptionDialog = vi.fn() +const mockCanManageSubscription = ref(true) +const mockIsMdOrLarger = ref(true) + +vi.mock('@/composables/billing/useBillingContext', () => ({ + useBillingContext: () => ({ + showSubscriptionDialog: mockShowSubscriptionDialog + }) +})) + +vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({ + useWorkspaceUI: () => ({ + permissions: computed(() => ({ + canManageSubscription: mockCanManageSubscription.value + })) + }) +})) + +vi.mock('@/platform/distribution/types', () => ({ + isCloud: true +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => null +})) + +vi.mock('@vueuse/core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useBreakpoints: () => ({ + greaterOrEqual: () => mockIsMdOrLarger + }) + } +}) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + subscription: { + subscribeToRun: 'Subscribe', + subscribeToRunFull: 'Subscribe to Run', + inactive: { + runLabel: 'Run', + memberRunTooltip: 'Contact your workspace owner to resubscribe' + } + } + } + } +}) + +function renderButton() { + const user = userEvent.setup() + const result = render(SubscribeToRun, { + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + return { ...result, user } +} + +describe('SubscribeToRun', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCanManageSubscription.value = true + mockIsMdOrLarger.value = true + }) + + it('shows the subscribe label for owners who can manage the subscription', () => { + renderButton() + + expect(screen.getByTestId('subscribe-to-run-button')).toHaveTextContent( + 'Subscribe to Run' + ) + }) + + it('shows a neutral run label for members who cannot subscribe', () => { + mockCanManageSubscription.value = false + renderButton() + + const button = screen.getByTestId('subscribe-to-run-button') + expect(button).toHaveTextContent('Run') + expect(button).not.toHaveTextContent('Subscribe') + }) + + it('opens the subscription dialog for owners on click', async () => { + const { user } = renderButton() + + await user.click(screen.getByTestId('subscribe-to-run-button')) + + expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce() + }) + + it('routes members to the same role-aware dialog on click', async () => { + mockCanManageSubscription.value = false + const { user } = renderButton() + + await user.click(screen.getByTestId('subscribe-to-run-button')) + + expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce() + }) +}) diff --git a/src/platform/cloud/subscription/components/SubscribeToRun.vue b/src/platform/cloud/subscription/components/SubscribeToRun.vue index 80976c2e96e..0d3c6f6d444 100644 --- a/src/platform/cloud/subscription/components/SubscribeToRun.vue +++ b/src/platform/cloud/subscription/components/SubscribeToRun.vue @@ -1,7 +1,7 @@ diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts index 3e25cca7a37..680af8da60a 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts @@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing' import { render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { computed, ref } from 'vue' +import { ref } from 'vue' import { createI18n } from 'vue-i18n' import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' @@ -16,7 +16,6 @@ const mockHandleConfirmTransition = vi.fn() const mockHandleResubscribe = vi.fn() const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing') const mockPreviewData = ref<{ transition_type: string } | null>(null) -const mockCanManageSubscription = ref(true) vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({ useSubscriptionCheckout: () => ({ @@ -37,14 +36,6 @@ vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({ }) })) -vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({ - useWorkspaceUI: () => ({ - permissions: computed(() => ({ - canManageSubscription: mockCanManageSubscription.value - })) - }) -})) - const i18n = createI18n({ legacy: false, locale: 'en', @@ -53,12 +44,7 @@ const i18n = createI18n({ g: { back: 'Back', close: 'Close' }, subscription: { plansForWorkspace: 'Plans for {workspace}', - teamWorkspace: 'Team', - inactive: { - memberTitle: 'This workspace is paused', - memberDescription: - 'Contact your workspace owner to resubscribe and continue running workflows.' - } + teamWorkspace: 'Team' }, credits: { topUp: { @@ -119,7 +105,6 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => { vi.clearAllMocks() mockCheckoutStep.value = 'pricing' mockPreviewData.value = null - mockCanManageSubscription.value = true }) it('shows pricing table on pricing step', () => { @@ -210,51 +195,4 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => { expect(mockHandleBackToPricing).toHaveBeenCalled() }) - - describe('member (cannot manage subscription)', () => { - beforeEach(() => { - mockCanManageSubscription.value = false - }) - - it('shows the contact-owner resubscribe message instead of the pricing table', () => { - renderComponent() - - expect( - screen.getByTestId('member-resubscribe-message') - ).toBeInTheDocument() - expect( - screen.getByText( - 'Contact your workspace owner to resubscribe and continue running workflows.' - ) - ).toBeInTheDocument() - }) - - it('does not expose any subscribe affordance to members', () => { - renderComponent() - - expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument() - expect(screen.queryByTestId('subscribe-btn')).not.toBeInTheDocument() - expect(screen.queryByTestId('resubscribe-btn')).not.toBeInTheDocument() - }) - - it('falls back to the credits flow when the reason is out_of_credits', () => { - renderComponent({ reason: 'out_of_credits' }) - - expect( - screen.queryByTestId('member-resubscribe-message') - ).not.toBeInTheDocument() - expect(screen.getByText('Insufficient Credits')).toBeInTheDocument() - }) - }) - - describe('owner (can manage subscription)', () => { - it('shows the pricing table and hides the member message', () => { - renderComponent() - - expect(screen.getByTestId('pricing-table')).toBeInTheDocument() - expect( - screen.queryByTestId('member-resubscribe-message') - ).not.toBeInTheDocument() - }) - }) }) diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue index 94a1ab0b063..f8a815ac372 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue @@ -3,7 +3,7 @@ class="relative flex h-full flex-col gap-6 overflow-y-auto p-4 pt-8 md:px-16 md:py-8" > -
+
+ -

- {{ $t('subscription.inactive.memberTitle') }} + + + +

+ +
+

+ {{ $t('credits.topUp.insufficientTitle') }}

-

- {{ $t('subscription.inactive.memberDescription') }} +

+ {{ $t('credits.topUp.insufficientMessage') }}

- + + + + + + + +