dify/web/app/components/__tests__/oauth-registration-analytics.spec.tsx
Coding On Star c6ca395042
fix(web): make registration tracking consent-safe and retryable (#41365)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-08-31 03:39:37 +00:00

241 lines
8.3 KiB
TypeScript

import { render, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { StrictMode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { useSearchParams } from '@/next/navigation'
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
const {
mockConsent,
mockNormalizeRegistrationAttribution,
mockRememberRegistrationSuccess,
mockSendGAEvent,
} = vi.hoisted(() => ({
mockConsent: { value: 'granted' as 'unknown' | 'denied' | 'granted' | 'disabled' },
mockNormalizeRegistrationAttribution: vi.fn((value: Record<string, unknown> | null) => {
if (!value) return null
const allowed = Object.fromEntries(
Object.entries(value).filter(
([key, item]) =>
['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'].includes(
key,
) && typeof item === 'string',
),
)
return Object.keys(allowed).length ? allowed : null
}),
mockRememberRegistrationSuccess: vi.fn(),
mockSendGAEvent: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('../base/analytics-consent/consent-store', () => ({
useAnalyticsConsent: () => mockConsent.value,
}))
vi.mock('../base/amplitude/registration-tracking', () => ({
normalizeRegistrationAttribution: (
...args: Parameters<typeof mockNormalizeRegistrationAttribution>
) => mockNormalizeRegistrationAttribution(...args),
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
const setSearchParams = (searchParams = '') => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>,
)
window.history.replaceState(null, '', `/signin${searchParams ? `?${searchParams}` : ''}`)
}
describe('OAuthRegistrationAnalytics', () => {
beforeEach(() => {
vi.clearAllMocks()
window.sessionStorage.clear()
mockConsent.value = 'granted'
mockRememberRegistrationSuccess.mockReturnValue(true)
Cookies.remove('utm_info')
vi.spyOn(console, 'error').mockImplementation(() => {})
setSearchParams()
})
it('queues the Amplitude marker while consent is unknown and cleans the URL after persist', async () => {
mockConsent.value = 'unknown'
Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin', slug: 'agent-launch' }))
setSearchParams('oauth_new_user=true&source=signin')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
method: 'oauth',
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
})
})
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
expect(Cookies.get('utm_info')).toBeUndefined()
expect(window.location.search).toBe('?source=signin')
})
it('keeps the recoverable OAuth signal when marker persistence fails', () => {
Cookies.set('utm_info', JSON.stringify({ utm_source: 'linkedin' }))
setSearchParams('oauth_new_user=true&source=signin')
mockRememberRegistrationSuccess.mockReturnValue(false)
render(<OAuthRegistrationAnalytics />)
expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)
expect(window.location.search).toBe('?oauth_new_user=true&source=signin')
expect(Cookies.get('utm_info')).toBeTruthy()
})
it('keeps the OAuth marker while consent is unknown, then cleans without a second Amplitude queue on denial', async () => {
mockConsent.value = 'unknown'
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
setSearchParams('oauth_new_user=true')
const { rerender } = render(<OAuthRegistrationAnalytics />)
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
mockConsent.value = 'denied'
rerender(<OAuthRegistrationAnalytics />)
await waitFor(() => expect(window.location.search).toBe(''))
expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1)
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('queues immediately with pre-granted consent and keeps only allowlisted UTM fields', async () => {
Cookies.set(
'utm_info',
JSON.stringify({
utm_source: 'linkedin',
slug: 'agent-launch',
arbitrary: 'discard-me',
utm_term: { nested: true },
}),
)
setSearchParams('oauth_new_user=true&source=signin')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
method: 'oauth',
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(Cookies.get('utm_info')).toBeUndefined()
expect(window.location.search).toBe('?source=signin')
})
it('uses the base event and cleans up when the UTM cookie is malformed', async () => {
Cookies.set('utm_info', '{invalid-json')
setSearchParams('oauth_new_user=true')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
method: 'oauth',
utmInfo: null,
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(console.error).toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('cleans a false OAuth marker immediately without tracking or clearing utm_info', async () => {
mockConsent.value = 'unknown'
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
setSearchParams('oauth_new_user=false')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => expect(window.location.search).toBe(''))
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBe(JSON.stringify({ utm_source: 'blog' }))
})
it('tracks GA and Amplitude once across StrictMode effects and rerenders', async () => {
setSearchParams('oauth_new_user=true')
const { rerender } = render(
<StrictMode>
<OAuthRegistrationAnalytics />
</StrictMode>,
)
rerender(
<StrictMode>
<OAuthRegistrationAnalytics />
</StrictMode>,
)
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
})
it('tracks GA once across an unknown-consent remount that simulates reload', async () => {
mockConsent.value = 'unknown'
setSearchParams('oauth_new_user=true')
const firstRender = render(<OAuthRegistrationAnalytics />)
await waitFor(() => expect(mockRememberRegistrationSuccess).toHaveBeenCalledTimes(1))
firstRender.unmount()
render(<OAuthRegistrationAnalytics />)
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
})
it('treats analytics-disabled consent as terminal and cleans without Amplitude', async () => {
mockConsent.value = 'disabled'
Cookies.set('utm_info', JSON.stringify({ utm_source: 'blog' }))
setSearchParams('oauth_new_user=true')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => expect(window.location.search).toBe(''))
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('does nothing without the OAuth registration query marker', () => {
render(<OAuthRegistrationAnalytics />)
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
it('clears an abandoned flow guard so a later OAuth registration can emit GA', () => {
window.sessionStorage.setItem('oauth_registration_ga_sent', 'true')
const abandonedFlow = render(<OAuthRegistrationAnalytics />)
abandonedFlow.unmount()
setSearchParams('oauth_new_user=true')
render(<OAuthRegistrationAnalytics />)
expect(mockSendGAEvent).toHaveBeenCalledTimes(1)
})
})