mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-30 12:59:55 +00:00
emits event after going to dashboard and returning to page and having subscription status change from subscribed to not subscribed. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6684-add-telemetry-event-for-subscription-cancellation-2aa6d73d365081009770de6d1db2b701) by [Unito](https://www.unito.io)
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ref } from 'vue'
|
|
|
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
|
|
|
// Create mocks
|
|
const mockIsLoggedIn = ref(false)
|
|
const mockReportError = vi.fn()
|
|
const mockAccessBillingPortal = vi.fn()
|
|
const mockShowSubscriptionRequiredDialog = vi.fn()
|
|
const mockGetAuthHeader = vi.fn(() =>
|
|
Promise.resolve({ Authorization: 'Bearer test-token' })
|
|
)
|
|
const mockTelemetry = {
|
|
trackSubscription: vi.fn(),
|
|
trackMonthlySubscriptionCancelled: vi.fn()
|
|
}
|
|
|
|
// Mock dependencies
|
|
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
|
useCurrentUser: vi.fn(() => ({
|
|
isLoggedIn: mockIsLoggedIn
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/platform/telemetry', () => ({
|
|
useTelemetry: vi.fn(() => mockTelemetry)
|
|
}))
|
|
|
|
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
|
useFirebaseAuthActions: vi.fn(() => ({
|
|
reportError: mockReportError,
|
|
accessBillingPortal: mockAccessBillingPortal
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/composables/useErrorHandling', () => ({
|
|
useErrorHandling: vi.fn(() => ({
|
|
wrapWithErrorHandlingAsync: vi.fn(
|
|
(fn, errorHandler) =>
|
|
async (...args: any[]) => {
|
|
try {
|
|
return await fn(...args)
|
|
} catch (error) {
|
|
if (errorHandler) {
|
|
errorHandler(error)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
)
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/platform/distribution/types', () => ({
|
|
isCloud: true
|
|
}))
|
|
|
|
vi.mock('@/services/dialogService', () => ({
|
|
useDialogService: vi.fn(() => ({
|
|
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/stores/firebaseAuthStore', () => ({
|
|
useFirebaseAuthStore: vi.fn(() => ({
|
|
getAuthHeader: mockGetAuthHeader
|
|
})),
|
|
FirebaseAuthStoreError: class extends Error {}
|
|
}))
|
|
|
|
// Mock fetch
|
|
global.fetch = vi.fn()
|
|
|
|
describe('useSubscription', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockIsLoggedIn.value = false
|
|
mockTelemetry.trackSubscription.mockReset()
|
|
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
|
window.__CONFIG__ = {
|
|
subscription_required: true
|
|
} as typeof window.__CONFIG__
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: false,
|
|
subscription_id: '',
|
|
renewal_date: ''
|
|
})
|
|
} as Response)
|
|
})
|
|
|
|
describe('computed properties', () => {
|
|
it('should compute isActiveSubscription correctly when subscription is active', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: true,
|
|
subscription_id: 'sub_123',
|
|
renewal_date: '2025-11-16'
|
|
})
|
|
} as Response)
|
|
|
|
mockIsLoggedIn.value = true
|
|
const { isActiveSubscription, fetchStatus } = useSubscription()
|
|
|
|
await fetchStatus()
|
|
expect(isActiveSubscription.value).toBe(true)
|
|
})
|
|
|
|
it('should compute isActiveSubscription as false when subscription is inactive', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: false,
|
|
subscription_id: 'sub_123',
|
|
renewal_date: '2025-11-16'
|
|
})
|
|
} as Response)
|
|
|
|
mockIsLoggedIn.value = true
|
|
const { isActiveSubscription, fetchStatus } = useSubscription()
|
|
|
|
await fetchStatus()
|
|
expect(isActiveSubscription.value).toBe(false)
|
|
})
|
|
|
|
it('should format renewal date correctly', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: true,
|
|
subscription_id: 'sub_123',
|
|
renewal_date: '2025-11-16T12:00:00Z'
|
|
})
|
|
} as Response)
|
|
|
|
mockIsLoggedIn.value = true
|
|
const { formattedRenewalDate, fetchStatus } = useSubscription()
|
|
|
|
await fetchStatus()
|
|
// The date format may vary based on timezone, so we just check it's a valid date string
|
|
expect(formattedRenewalDate.value).toMatch(/^[A-Za-z]{3} \d{1,2}, \d{4}$/)
|
|
expect(formattedRenewalDate.value).toContain('2025')
|
|
expect(formattedRenewalDate.value).toContain('Nov')
|
|
})
|
|
|
|
it('should return empty string when renewal date is not available', () => {
|
|
const { formattedRenewalDate } = useSubscription()
|
|
|
|
expect(formattedRenewalDate.value).toBe('')
|
|
})
|
|
|
|
it('should format monthly price correctly', () => {
|
|
const { formattedMonthlyPrice } = useSubscription()
|
|
|
|
expect(formattedMonthlyPrice.value).toBe('$20')
|
|
})
|
|
})
|
|
|
|
describe('fetchStatus', () => {
|
|
it('should fetch subscription status successfully', async () => {
|
|
const mockStatus = {
|
|
is_active: true,
|
|
subscription_id: 'sub_123',
|
|
renewal_date: '2025-11-16'
|
|
}
|
|
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => mockStatus
|
|
} as Response)
|
|
|
|
mockIsLoggedIn.value = true
|
|
const { fetchStatus } = useSubscription()
|
|
|
|
await fetchStatus()
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/customers/cloud-subscription-status'),
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer test-token',
|
|
'Content-Type': 'application/json'
|
|
})
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should handle fetch errors gracefully', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: false,
|
|
json: async () => ({ message: 'Subscription not found' })
|
|
} as Response)
|
|
|
|
const { fetchStatus } = useSubscription()
|
|
|
|
await expect(fetchStatus()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('subscribe', () => {
|
|
it('should initiate subscription checkout successfully', async () => {
|
|
const checkoutUrl = 'https://checkout.stripe.com/test'
|
|
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ checkout_url: checkoutUrl })
|
|
} as Response)
|
|
|
|
// Mock window.open
|
|
const windowOpenSpy = vi
|
|
.spyOn(window, 'open')
|
|
.mockImplementation(() => null)
|
|
|
|
const { subscribe } = useSubscription()
|
|
|
|
await subscribe()
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining('/customers/cloud-subscription-checkout'),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer test-token',
|
|
'Content-Type': 'application/json'
|
|
})
|
|
})
|
|
)
|
|
|
|
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
|
|
|
windowOpenSpy.mockRestore()
|
|
})
|
|
|
|
it('should throw error when checkout URL is not returned', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({})
|
|
} as Response)
|
|
|
|
const { subscribe } = useSubscription()
|
|
|
|
await expect(subscribe()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('requireActiveSubscription', () => {
|
|
it('should not show dialog when subscription is active', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: true,
|
|
subscription_id: 'sub_123',
|
|
renewal_date: '2025-11-16'
|
|
})
|
|
} as Response)
|
|
|
|
const { requireActiveSubscription } = useSubscription()
|
|
|
|
await requireActiveSubscription()
|
|
|
|
expect(mockShowSubscriptionRequiredDialog).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should show dialog when subscription is inactive', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: false,
|
|
subscription_id: 'sub_123',
|
|
renewal_date: '2025-11-16'
|
|
})
|
|
} as Response)
|
|
|
|
const { requireActiveSubscription } = useSubscription()
|
|
|
|
await requireActiveSubscription()
|
|
|
|
expect(mockShowSubscriptionRequiredDialog).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('action handlers', () => {
|
|
it('should open usage history URL', () => {
|
|
const windowOpenSpy = vi
|
|
.spyOn(window, 'open')
|
|
.mockImplementation(() => null)
|
|
|
|
const { handleViewUsageHistory } = useSubscription()
|
|
handleViewUsageHistory()
|
|
|
|
expect(windowOpenSpy).toHaveBeenCalledWith(
|
|
'https://stagingplatform.comfy.org/profile/usage',
|
|
'_blank'
|
|
)
|
|
|
|
windowOpenSpy.mockRestore()
|
|
})
|
|
|
|
it('should open learn more URL', () => {
|
|
const windowOpenSpy = vi
|
|
.spyOn(window, 'open')
|
|
.mockImplementation(() => null)
|
|
|
|
const { handleLearnMore } = useSubscription()
|
|
handleLearnMore()
|
|
|
|
expect(windowOpenSpy).toHaveBeenCalledWith(
|
|
'https://docs.comfy.org',
|
|
'_blank'
|
|
)
|
|
|
|
windowOpenSpy.mockRestore()
|
|
})
|
|
|
|
it('should call accessBillingPortal for invoice history', async () => {
|
|
const { handleInvoiceHistory } = useSubscription()
|
|
|
|
await handleInvoiceHistory()
|
|
|
|
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should call accessBillingPortal for manage subscription', async () => {
|
|
const { manageSubscription } = useSubscription()
|
|
|
|
await manageSubscription()
|
|
|
|
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
|
})
|
|
|
|
it('tracks cancellation after manage subscription when status flips', async () => {
|
|
vi.useFakeTimers()
|
|
mockIsLoggedIn.value = true
|
|
|
|
const activeResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: true,
|
|
subscription_id: 'sub_active',
|
|
renewal_date: '2025-11-16'
|
|
})
|
|
}
|
|
|
|
const cancelledResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: false,
|
|
subscription_id: 'sub_cancelled',
|
|
renewal_date: '2025-11-16',
|
|
end_date: '2025-12-01'
|
|
})
|
|
}
|
|
|
|
vi.mocked(global.fetch)
|
|
.mockResolvedValueOnce(activeResponse as Response)
|
|
.mockResolvedValueOnce(activeResponse as Response)
|
|
.mockResolvedValueOnce(cancelledResponse as Response)
|
|
|
|
try {
|
|
const { fetchStatus, manageSubscription } = useSubscription()
|
|
|
|
await fetchStatus()
|
|
await manageSubscription()
|
|
|
|
await vi.advanceTimersByTimeAsync(5000)
|
|
|
|
expect(
|
|
mockTelemetry.trackMonthlySubscriptionCancelled
|
|
).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('handles rapid focus events during cancellation polling', async () => {
|
|
vi.useFakeTimers()
|
|
mockIsLoggedIn.value = true
|
|
|
|
const activeResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: true,
|
|
subscription_id: 'sub_active',
|
|
renewal_date: '2025-11-16'
|
|
})
|
|
}
|
|
|
|
const cancelledResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
is_active: false,
|
|
subscription_id: 'sub_cancelled',
|
|
renewal_date: '2025-11-16',
|
|
end_date: '2025-12-01'
|
|
})
|
|
}
|
|
|
|
vi.mocked(global.fetch)
|
|
.mockResolvedValueOnce(activeResponse as Response)
|
|
.mockResolvedValueOnce(activeResponse as Response)
|
|
.mockResolvedValueOnce(cancelledResponse as Response)
|
|
|
|
try {
|
|
const { fetchStatus, manageSubscription } = useSubscription()
|
|
|
|
await fetchStatus()
|
|
await manageSubscription()
|
|
|
|
window.dispatchEvent(new Event('focus'))
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
mockTelemetry.trackMonthlySubscriptionCancelled
|
|
).toHaveBeenCalledTimes(1)
|
|
})
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|
|
})
|