-
Notifications
You must be signed in to change notification settings - Fork 649
feat: pass target tier to billing portal for subscription updates #7692
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
d5e0b99
d69d413
ca590d6
245c804
d90e1f2
a3d4327
432657b
f0296f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| import { createTestingPinia } from '@pinia/testing' | ||
| import { flushPromises, mount } from '@vue/test-utils' | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { computed, ref } from 'vue' | ||
| import { createI18n } from 'vue-i18n' | ||
|
|
||
| import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue' | ||
|
|
||
| const mockIsActiveSubscription = ref(false) | ||
| const mockSubscriptionTier = ref< | ||
| 'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null | ||
| >(null) | ||
| const mockAccessBillingPortal = vi.fn() | ||
| const mockReportError = vi.fn() | ||
| const mockGetAuthHeader = vi.fn(() => | ||
| Promise.resolve({ Authorization: 'Bearer test-token' }) | ||
| ) | ||
|
|
||
| vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ | ||
| useSubscription: () => ({ | ||
| isActiveSubscription: computed(() => mockIsActiveSubscription.value), | ||
| subscriptionTier: computed(() => mockSubscriptionTier.value) | ||
| }) | ||
| })) | ||
|
|
||
| vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ | ||
| useFirebaseAuthActions: () => ({ | ||
| accessBillingPortal: mockAccessBillingPortal, | ||
| reportError: mockReportError | ||
| }) | ||
| })) | ||
|
|
||
| vi.mock('@/composables/useErrorHandling', () => ({ | ||
| useErrorHandling: () => ({ | ||
| wrapWithErrorHandlingAsync: vi.fn( | ||
| (fn, errorHandler) => | ||
| async (...args: unknown[]) => { | ||
| try { | ||
| return await fn(...args) | ||
| } catch (error) { | ||
| if (errorHandler) { | ||
| errorHandler(error) | ||
| } | ||
| throw error | ||
| } | ||
| } | ||
| ) | ||
| }) | ||
| })) | ||
|
|
||
| vi.mock('@/stores/firebaseAuthStore', () => ({ | ||
| useFirebaseAuthStore: () => ({ | ||
| getAuthHeader: mockGetAuthHeader | ||
| }), | ||
| FirebaseAuthStoreError: class extends Error {} | ||
| })) | ||
|
|
||
| vi.mock('@/platform/distribution/types', () => ({ | ||
| isCloud: true | ||
| })) | ||
|
|
||
| global.fetch = vi.fn() | ||
|
|
||
| const i18n = createI18n({ | ||
| legacy: false, | ||
| locale: 'en', | ||
| messages: { | ||
| en: { | ||
| subscription: { | ||
| yearly: 'Yearly', | ||
| monthly: 'Monthly', | ||
| mostPopular: 'Most Popular', | ||
| usdPerMonth: '/ month', | ||
| billedYearly: 'Billed yearly ({total})', | ||
| billedMonthly: 'Billed monthly', | ||
| currentPlan: 'Current Plan', | ||
| subscribeTo: 'Subscribe to {plan}', | ||
| changeTo: 'Change to {plan}', | ||
| maxDuration: { | ||
| standard: '30 min', | ||
| creator: '30 min', | ||
| pro: '1 hr' | ||
| }, | ||
| tiers: { | ||
| standard: { name: 'Standard' }, | ||
| creator: { name: 'Creator' }, | ||
| pro: { name: 'Pro' } | ||
| }, | ||
| benefits: { | ||
| monthlyCredits: '{credits} monthly credits', | ||
| maxDuration: '{duration} max duration', | ||
| gpu: 'RTX 6000 Pro GPU', | ||
| addCredits: 'Add more credits anytime', | ||
| customLoRAs: 'Import custom LoRAs' | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| function createWrapper() { | ||
| return mount(PricingTable, { | ||
| global: { | ||
| plugins: [createTestingPinia({ createSpy: vi.fn }), i18n], | ||
| stubs: { | ||
| SelectButton: { | ||
| template: '<div><slot /></div>', | ||
| props: ['modelValue', 'options'], | ||
| emits: ['update:modelValue'] | ||
| }, | ||
| Popover: { template: '<div><slot /></div>' }, | ||
| Button: { | ||
| template: | ||
| '<button @click="$emit(\'click\')" :disabled="disabled" :data-tier="dataTier">{{ label }}</button>', | ||
| props: ['loading', 'label', 'severity', 'disabled', 'dataTier', 'pt'], | ||
| emits: ['click'] | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| describe('PricingTable', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| mockIsActiveSubscription.value = false | ||
| mockSubscriptionTier.value = null | ||
| vi.mocked(global.fetch).mockResolvedValue({ | ||
| ok: true, | ||
| json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' }) | ||
| } as Response) | ||
| }) | ||
|
|
||
| describe('billing portal deep linking', () => { | ||
| it('should call accessBillingPortal with yearly tier suffix when billing cycle is yearly (default)', async () => { | ||
| mockIsActiveSubscription.value = true | ||
| mockSubscriptionTier.value = 'STANDARD' | ||
|
|
||
| const wrapper = createWrapper() | ||
| await flushPromises() | ||
|
|
||
| const creatorButton = wrapper | ||
| .findAll('button') | ||
| .find((btn) => btn.text().includes('Creator')) | ||
|
|
||
| expect(creatorButton).toBeDefined() | ||
| await creatorButton?.trigger('click') | ||
| await flushPromises() | ||
|
|
||
| expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly') | ||
| }) | ||
|
|
||
| it('should call accessBillingPortal with different tiers correctly', async () => { | ||
| mockIsActiveSubscription.value = true | ||
| mockSubscriptionTier.value = 'STANDARD' | ||
|
|
||
| const wrapper = createWrapper() | ||
| await flushPromises() | ||
|
|
||
| const proButton = wrapper | ||
| .findAll('button') | ||
| .find((btn) => btn.text().includes('Pro')) | ||
|
|
||
| await proButton?.trigger('click') | ||
| await flushPromises() | ||
|
|
||
| expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly') | ||
| }) | ||
|
|
||
| it('should not call accessBillingPortal when clicking current plan', async () => { | ||
| mockIsActiveSubscription.value = true | ||
| mockSubscriptionTier.value = 'CREATOR' | ||
|
|
||
| const wrapper = createWrapper() | ||
| await flushPromises() | ||
|
|
||
| const currentPlanButton = wrapper | ||
| .findAll('button') | ||
| .find((btn) => btn.text().includes('Current Plan')) | ||
|
|
||
| await currentPlanButton?.trigger('click') | ||
| await flushPromises() | ||
|
|
||
| expect(mockAccessBillingPortal).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('should initiate checkout instead of billing portal for new subscribers', async () => { | ||
| mockIsActiveSubscription.value = false | ||
|
|
||
| const windowOpenSpy = vi | ||
| .spyOn(window, 'open') | ||
| .mockImplementation(() => null) | ||
|
|
||
| const wrapper = createWrapper() | ||
| await flushPromises() | ||
|
|
||
| const subscribeButton = wrapper | ||
| .findAll('button') | ||
| .find((btn) => btn.text().includes('Subscribe')) | ||
|
|
||
| await subscribeButton?.trigger('click') | ||
| await flushPromises() | ||
|
|
||
| expect(mockAccessBillingPortal).not.toHaveBeenCalled() | ||
| expect(global.fetch).toHaveBeenCalledWith( | ||
| expect.stringContaining('/customers/cloud-subscription-checkout/'), | ||
| expect.any(Object) | ||
| ) | ||
| expect(windowOpenSpy).toHaveBeenCalledWith( | ||
| 'https://checkout.stripe.com/test', | ||
| '_blank' | ||
| ) | ||
|
|
||
| windowOpenSpy.mockRestore() | ||
| }) | ||
|
|
||
| it('should pass correct tier for each subscription level', async () => { | ||
| mockIsActiveSubscription.value = true | ||
| mockSubscriptionTier.value = 'PRO' | ||
|
|
||
| const wrapper = createWrapper() | ||
| await flushPromises() | ||
|
|
||
| const standardButton = wrapper | ||
| .findAll('button') | ||
| .find((btn) => btn.text().includes('Standard')) | ||
|
|
||
| await standardButton?.trigger('click') | ||
| await flushPromises() | ||
|
|
||
| expect(mockAccessBillingPortal).toHaveBeenCalledWith('standard-yearly') | ||
| }) | ||
| }) | ||
| }) | ||
|
Comment on lines
+125
to
+237
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider adding error handling tests. While the current test coverage is solid for happy paths, consider adding tests for error scenarios such as:
These tests would verify that the Example error handling testit('should handle billing portal errors gracefully', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
mockAccessBillingPortal.mockRejectedValueOnce(new Error('Portal error'))
const wrapper = createWrapper()
await flushPromises()
const creatorButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Creator'))
await creatorButton?.trigger('click')
await flushPromises()
expect(mockReportError).toHaveBeenCalled()
}) |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add test coverage for monthly billing cycle deep-linking.
The PR description states that billing cycle information (yearly vs monthly) is passed to the billing portal. However, all tests only verify the yearly suffix (
-yearly). There are no tests verifying that monthly subscriptions pass the correct tier suffix (e.g.,creator-monthly,pro-monthly).Add tests that set
mockIsYearlySubscription.value = trueand verify the billing portal is called with monthly tier suffixes.🔎 Suggested test for monthly billing cycle
📝 Committable suggestion
🤖 Prompt for AI Agents