mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
add telemetry event for subscription cancellation (#6684)
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)
This commit is contained in:
@@ -14,12 +14,13 @@ import {
|
|||||||
FirebaseAuthStoreError,
|
FirebaseAuthStoreError,
|
||||||
useFirebaseAuthStore
|
useFirebaseAuthStore
|
||||||
} from '@/stores/firebaseAuthStore'
|
} from '@/stores/firebaseAuthStore'
|
||||||
|
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||||
|
|
||||||
type CloudSubscriptionCheckoutResponse = {
|
type CloudSubscriptionCheckoutResponse = {
|
||||||
checkout_url: string
|
checkout_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CloudSubscriptionStatusResponse = {
|
export type CloudSubscriptionStatusResponse = {
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
subscription_id: string
|
subscription_id: string
|
||||||
renewal_date: string | null
|
renewal_date: string | null
|
||||||
@@ -28,6 +29,7 @@ type CloudSubscriptionStatusResponse = {
|
|||||||
|
|
||||||
function useSubscriptionInternal() {
|
function useSubscriptionInternal() {
|
||||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
const isSubscribedOrIsNotCloud = computed(() => {
|
const isSubscribedOrIsNotCloud = computed(() => {
|
||||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||||
@@ -103,8 +105,21 @@ function useSubscriptionInternal() {
|
|||||||
void dialogService.showSubscriptionRequiredDialog()
|
void dialogService.showSubscriptionRequiredDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldWatchCancellation = (): boolean =>
|
||||||
|
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||||
|
|
||||||
|
const { startCancellationWatcher, stopCancellationWatcher } =
|
||||||
|
useSubscriptionCancellationWatcher({
|
||||||
|
fetchStatus,
|
||||||
|
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||||
|
subscriptionStatus,
|
||||||
|
telemetry,
|
||||||
|
shouldWatchCancellation
|
||||||
|
})
|
||||||
|
|
||||||
const manageSubscription = async () => {
|
const manageSubscription = async () => {
|
||||||
await accessBillingPortal()
|
await accessBillingPortal()
|
||||||
|
startCancellationWatcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
const requireActiveSubscription = async (): Promise<void> => {
|
const requireActiveSubscription = async (): Promise<void> => {
|
||||||
@@ -168,6 +183,7 @@ function useSubscriptionInternal() {
|
|||||||
await fetchSubscriptionStatus()
|
await fetchSubscriptionStatus()
|
||||||
} else {
|
} else {
|
||||||
subscriptionStatus.value = null
|
subscriptionStatus.value = null
|
||||||
|
stopCancellationWatcher()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { onScopeDispose, ref } from 'vue'
|
||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import { defaultWindow, useEventListener, useTimeoutFn } from '@vueuse/core'
|
||||||
|
|
||||||
|
import type { TelemetryProvider } from '@/platform/telemetry/types'
|
||||||
|
|
||||||
|
import type { CloudSubscriptionStatusResponse } from './useSubscription'
|
||||||
|
|
||||||
|
const MAX_CANCELLATION_ATTEMPTS = 4
|
||||||
|
const CANCELLATION_BASE_DELAY_MS = 5000
|
||||||
|
const CANCELLATION_BACKOFF_MULTIPLIER = 3 // 5s, 15s, 45s, 135s intervals
|
||||||
|
|
||||||
|
type CancellationWatcherOptions = {
|
||||||
|
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
|
||||||
|
isActiveSubscription: ComputedRef<boolean>
|
||||||
|
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
|
||||||
|
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
|
||||||
|
shouldWatchCancellation: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscriptionCancellationWatcher({
|
||||||
|
fetchStatus,
|
||||||
|
isActiveSubscription,
|
||||||
|
subscriptionStatus,
|
||||||
|
telemetry,
|
||||||
|
shouldWatchCancellation
|
||||||
|
}: CancellationWatcherOptions) {
|
||||||
|
const watcherActive = ref(false)
|
||||||
|
const cancellationAttempts = ref(0)
|
||||||
|
const cancellationTracked = ref(false)
|
||||||
|
const cancellationCheckInFlight = ref(false)
|
||||||
|
const nextDelay = ref(CANCELLATION_BASE_DELAY_MS)
|
||||||
|
let detachFocusListener: (() => void) | null = null
|
||||||
|
|
||||||
|
const { start: startTimer, stop: stopTimer } = useTimeoutFn(
|
||||||
|
() => {
|
||||||
|
void checkForCancellation()
|
||||||
|
},
|
||||||
|
nextDelay,
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const stopCancellationWatcher = () => {
|
||||||
|
watcherActive.value = false
|
||||||
|
stopTimer()
|
||||||
|
cancellationAttempts.value = 0
|
||||||
|
cancellationCheckInFlight.value = false
|
||||||
|
if (detachFocusListener) {
|
||||||
|
detachFocusListener()
|
||||||
|
detachFocusListener = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleNextCancellationCheck = () => {
|
||||||
|
if (!watcherActive.value) return
|
||||||
|
|
||||||
|
if (cancellationAttempts.value >= MAX_CANCELLATION_ATTEMPTS) {
|
||||||
|
stopCancellationWatcher()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextDelay.value =
|
||||||
|
CANCELLATION_BASE_DELAY_MS *
|
||||||
|
CANCELLATION_BACKOFF_MULTIPLIER ** cancellationAttempts.value
|
||||||
|
cancellationAttempts.value += 1
|
||||||
|
startTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkForCancellation = async (triggeredFromFocus = false) => {
|
||||||
|
if (!watcherActive.value || cancellationCheckInFlight.value) return
|
||||||
|
|
||||||
|
cancellationCheckInFlight.value = true
|
||||||
|
try {
|
||||||
|
await fetchStatus()
|
||||||
|
|
||||||
|
if (!isActiveSubscription.value) {
|
||||||
|
if (!cancellationTracked.value) {
|
||||||
|
cancellationTracked.value = true
|
||||||
|
try {
|
||||||
|
telemetry?.trackMonthlySubscriptionCancelled()
|
||||||
|
} catch (telemetryError) {
|
||||||
|
console.error(
|
||||||
|
'[Subscription] Failed to track cancellation telemetry:',
|
||||||
|
telemetryError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopCancellationWatcher()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!triggeredFromFocus) {
|
||||||
|
scheduleNextCancellationCheck()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Subscription] Error checking cancellation status:', error)
|
||||||
|
scheduleNextCancellationCheck()
|
||||||
|
} finally {
|
||||||
|
cancellationCheckInFlight.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCancellationWatcher = () => {
|
||||||
|
if (!shouldWatchCancellation() || !subscriptionStatus.value?.is_active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCancellationWatcher()
|
||||||
|
watcherActive.value = true
|
||||||
|
cancellationTracked.value = false
|
||||||
|
cancellationAttempts.value = 0
|
||||||
|
if (!detachFocusListener && defaultWindow) {
|
||||||
|
detachFocusListener = useEventListener(defaultWindow, 'focus', () => {
|
||||||
|
if (!watcherActive.value) return
|
||||||
|
void checkForCancellation(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
scheduleNextCancellationCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
stopCancellationWatcher()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
startCancellationWatcher,
|
||||||
|
stopCancellationWatcher
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -175,6 +175,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
|||||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track when a user completes a subscription cancellation flow.
|
||||||
|
* Fired after we detect the backend reports `is_active: false` and the UI stops polling.
|
||||||
|
*/
|
||||||
|
trackMonthlySubscriptionCancelled(): void {
|
||||||
|
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||||
|
}
|
||||||
|
|
||||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||||
const metadata: CreditTopupMetadata = {
|
const metadata: CreditTopupMetadata = {
|
||||||
credit_amount: amount
|
credit_amount: amount
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export interface TelemetryProvider {
|
|||||||
// Subscription flow events
|
// Subscription flow events
|
||||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||||
trackMonthlySubscriptionSucceeded(): void
|
trackMonthlySubscriptionSucceeded(): void
|
||||||
|
trackMonthlySubscriptionCancelled(): void
|
||||||
trackAddApiCreditButtonClicked(): void
|
trackAddApiCreditButtonClicked(): void
|
||||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
||||||
trackApiCreditTopupSucceeded(): void
|
trackApiCreditTopupSucceeded(): void
|
||||||
@@ -344,6 +345,7 @@ export const TelemetryEvents = {
|
|||||||
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
|
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
|
||||||
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
|
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
|
||||||
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
|
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
|
||||||
|
MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled',
|
||||||
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
|
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
|
||||||
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
||||||
'app:api_credit_topup_button_purchase_clicked',
|
'app:api_credit_topup_button_purchase_clicked',
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const mockShowSubscriptionRequiredDialog = vi.fn()
|
|||||||
const mockGetAuthHeader = vi.fn(() =>
|
const mockGetAuthHeader = vi.fn(() =>
|
||||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||||
)
|
)
|
||||||
|
const mockTelemetry = {
|
||||||
|
trackSubscription: vi.fn(),
|
||||||
|
trackMonthlySubscriptionCancelled: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||||
@@ -20,7 +24,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/platform/telemetry', () => ({
|
vi.mock('@/platform/telemetry', () => ({
|
||||||
useTelemetry: vi.fn(() => null)
|
useTelemetry: vi.fn(() => mockTelemetry)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||||
@@ -72,6 +76,11 @@ describe('useSubscription', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockIsLoggedIn.value = false
|
mockIsLoggedIn.value = false
|
||||||
|
mockTelemetry.trackSubscription.mockReset()
|
||||||
|
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||||
|
window.__CONFIG__ = {
|
||||||
|
subscription_required: true
|
||||||
|
} as typeof window.__CONFIG__
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -321,5 +330,94 @@ describe('useSubscription', () => {
|
|||||||
|
|
||||||
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { computed, effectScope, ref } from 'vue'
|
||||||
|
import type { EffectScope } from 'vue'
|
||||||
|
|
||||||
|
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
|
import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher'
|
||||||
|
|
||||||
|
describe('useSubscriptionCancellationWatcher', () => {
|
||||||
|
const trackMonthlySubscriptionCancelled = vi.fn()
|
||||||
|
const telemetryMock: Pick<
|
||||||
|
import('@/platform/telemetry/types').TelemetryProvider,
|
||||||
|
'trackMonthlySubscriptionCancelled'
|
||||||
|
> = {
|
||||||
|
trackMonthlySubscriptionCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseStatus: CloudSubscriptionStatusResponse = {
|
||||||
|
is_active: true,
|
||||||
|
subscription_id: 'sub_123',
|
||||||
|
renewal_date: '2025-11-16'
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(
|
||||||
|
baseStatus
|
||||||
|
)
|
||||||
|
const isActive = ref(true)
|
||||||
|
const isActiveSubscription = computed(() => isActive.value)
|
||||||
|
|
||||||
|
let shouldWatch = true
|
||||||
|
const shouldWatchCancellation = () => shouldWatch
|
||||||
|
|
||||||
|
const activeScopes: EffectScope[] = []
|
||||||
|
|
||||||
|
const initWatcher = (
|
||||||
|
options: Parameters<typeof useSubscriptionCancellationWatcher>[0]
|
||||||
|
): ReturnType<typeof useSubscriptionCancellationWatcher> => {
|
||||||
|
const scope = effectScope()
|
||||||
|
let result: ReturnType<typeof useSubscriptionCancellationWatcher> | null =
|
||||||
|
null
|
||||||
|
scope.run(() => {
|
||||||
|
result = useSubscriptionCancellationWatcher(options)
|
||||||
|
})
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Failed to initialize cancellation watcher')
|
||||||
|
}
|
||||||
|
activeScopes.push(scope)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
trackMonthlySubscriptionCancelled.mockReset()
|
||||||
|
subscriptionStatus.value = { ...baseStatus }
|
||||||
|
isActive.value = true
|
||||||
|
shouldWatch = true
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
activeScopes.forEach((scope) => scope.stop())
|
||||||
|
activeScopes.length = 0
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polls with exponential backoff and fires telemetry once cancellation detected', async () => {
|
||||||
|
const fetchStatus = vi.fn(async () => {
|
||||||
|
if (fetchStatus.mock.calls.length === 2) {
|
||||||
|
isActive.value = false
|
||||||
|
subscriptionStatus.value = {
|
||||||
|
is_active: false,
|
||||||
|
subscription_id: 'sub_cancelled',
|
||||||
|
renewal_date: '2025-11-16',
|
||||||
|
end_date: '2025-12-01'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { startCancellationWatcher } = initWatcher({
|
||||||
|
fetchStatus,
|
||||||
|
isActiveSubscription,
|
||||||
|
subscriptionStatus,
|
||||||
|
telemetry: telemetryMock,
|
||||||
|
shouldWatchCancellation
|
||||||
|
})
|
||||||
|
|
||||||
|
startCancellationWatcher()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000)
|
||||||
|
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(15000)
|
||||||
|
expect(fetchStatus).toHaveBeenCalledTimes(2)
|
||||||
|
expect(
|
||||||
|
telemetryMock.trackMonthlySubscriptionCancelled
|
||||||
|
).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers a check immediately when window regains focus', async () => {
|
||||||
|
const fetchStatus = vi.fn(async () => {
|
||||||
|
isActive.value = false
|
||||||
|
subscriptionStatus.value = {
|
||||||
|
...baseStatus,
|
||||||
|
is_active: false,
|
||||||
|
end_date: '2025-12-01'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { startCancellationWatcher } = initWatcher({
|
||||||
|
fetchStatus,
|
||||||
|
isActiveSubscription,
|
||||||
|
subscriptionStatus,
|
||||||
|
telemetry: telemetryMock,
|
||||||
|
shouldWatchCancellation
|
||||||
|
})
|
||||||
|
|
||||||
|
startCancellationWatcher()
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('focus'))
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||||
|
expect(
|
||||||
|
telemetryMock.trackMonthlySubscriptionCancelled
|
||||||
|
).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops after max attempts when subscription stays active', async () => {
|
||||||
|
const fetchStatus = vi.fn(async () => {})
|
||||||
|
|
||||||
|
const { startCancellationWatcher } = initWatcher({
|
||||||
|
fetchStatus,
|
||||||
|
isActiveSubscription,
|
||||||
|
subscriptionStatus,
|
||||||
|
telemetry: telemetryMock,
|
||||||
|
shouldWatchCancellation
|
||||||
|
})
|
||||||
|
|
||||||
|
startCancellationWatcher()
|
||||||
|
|
||||||
|
const delays = [5000, 15000, 45000, 135000]
|
||||||
|
for (const delay of delays) {
|
||||||
|
await vi.advanceTimersByTimeAsync(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fetchStatus).toHaveBeenCalledTimes(4)
|
||||||
|
expect(trackMonthlySubscriptionCancelled).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(200000)
|
||||||
|
expect(fetchStatus).toHaveBeenCalledTimes(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start watcher when guard fails or subscription inactive', async () => {
|
||||||
|
const fetchStatus = vi.fn()
|
||||||
|
|
||||||
|
const { startCancellationWatcher } = initWatcher({
|
||||||
|
fetchStatus,
|
||||||
|
isActiveSubscription,
|
||||||
|
subscriptionStatus,
|
||||||
|
telemetry: telemetryMock,
|
||||||
|
shouldWatchCancellation
|
||||||
|
})
|
||||||
|
|
||||||
|
shouldWatch = false
|
||||||
|
startCancellationWatcher()
|
||||||
|
await vi.advanceTimersByTimeAsync(60000)
|
||||||
|
expect(fetchStatus).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
shouldWatch = true
|
||||||
|
isActive.value = false
|
||||||
|
subscriptionStatus.value = {
|
||||||
|
...baseStatus,
|
||||||
|
is_active: false
|
||||||
|
}
|
||||||
|
startCancellationWatcher()
|
||||||
|
await vi.advanceTimersByTimeAsync(60000)
|
||||||
|
expect(fetchStatus).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user