mirror of https://github.com/langgenius/dify.git
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
/**
|
|
* Integration test: Partner Stack Flow
|
|
*
|
|
* Tests the PartnerStack integration:
|
|
* PartnerStack component → usePSInfo hook → cookie management → bind API call
|
|
*
|
|
* Covers URL param reading, cookie persistence, API bind on mount,
|
|
* cookie cleanup after successful bind, and error handling for 400 status.
|
|
*/
|
|
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
|
|
import Cookies from 'js-cookie'
|
|
import * as React from 'react'
|
|
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
|
|
import { PARTNER_STACK_CONFIG } from '@/config'
|
|
|
|
// ─── Mock state ──────────────────────────────────────────────────────────────
|
|
let mockSearchParams = new URLSearchParams()
|
|
const mockMutateAsync = vi.fn()
|
|
|
|
// ─── Module mocks ────────────────────────────────────────────────────────────
|
|
vi.mock('next/navigation', () => ({
|
|
useSearchParams: () => mockSearchParams,
|
|
useRouter: () => ({ push: vi.fn() }),
|
|
usePathname: () => '/',
|
|
}))
|
|
|
|
vi.mock('@/service/use-billing', () => ({
|
|
useBindPartnerStackInfo: () => ({
|
|
mutateAsync: mockMutateAsync,
|
|
}),
|
|
useBillingUrl: () => ({
|
|
data: '',
|
|
isFetching: false,
|
|
refetch: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/config', async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>()
|
|
return {
|
|
...actual,
|
|
IS_CLOUD_EDITION: true,
|
|
PARTNER_STACK_CONFIG: {
|
|
cookieName: 'partner_stack_info',
|
|
saveCookieDays: 90,
|
|
},
|
|
}
|
|
})
|
|
|
|
// ─── Cookie helpers ──────────────────────────────────────────────────────────
|
|
const getCookieData = () => {
|
|
const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName)
|
|
if (!raw)
|
|
return null
|
|
try {
|
|
return JSON.parse(raw)
|
|
}
|
|
catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const setCookieData = (data: Record<string, string>) => {
|
|
Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data))
|
|
}
|
|
|
|
const clearCookie = () => {
|
|
Cookies.remove(PARTNER_STACK_CONFIG.cookieName)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
describe('Partner Stack Flow', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
cleanup()
|
|
clearCookie()
|
|
mockSearchParams = new URLSearchParams()
|
|
mockMutateAsync.mockResolvedValue({})
|
|
})
|
|
|
|
// ─── 1. URL Param Reading ───────────────────────────────────────────────
|
|
describe('URL param reading', () => {
|
|
it('should read ps_partner_key and ps_xid from URL search params', () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'partner-123',
|
|
ps_xid: 'click-456',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
|
|
expect(result.current.psPartnerKey).toBe('partner-123')
|
|
expect(result.current.psClickId).toBe('click-456')
|
|
})
|
|
|
|
it('should fall back to cookie when URL params are not present', () => {
|
|
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
|
|
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
|
expect(result.current.psClickId).toBe('cookie-click')
|
|
})
|
|
|
|
it('should prefer URL params over cookie values', () => {
|
|
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'url-partner',
|
|
ps_xid: 'url-click',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
|
|
expect(result.current.psPartnerKey).toBe('url-partner')
|
|
expect(result.current.psClickId).toBe('url-click')
|
|
})
|
|
|
|
it('should return null for both values when no params and no cookie', () => {
|
|
const { result } = renderHook(() => usePSInfo())
|
|
|
|
expect(result.current.psPartnerKey).toBeUndefined()
|
|
expect(result.current.psClickId).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ─── 2. Cookie Persistence (saveOrUpdate) ───────────────────────────────
|
|
describe('Cookie persistence via saveOrUpdate', () => {
|
|
it('should save PS info to cookie when URL params provide new values', () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'new-partner',
|
|
ps_xid: 'new-click',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
act(() => result.current.saveOrUpdate())
|
|
|
|
const cookieData = getCookieData()
|
|
expect(cookieData).toEqual({
|
|
partnerKey: 'new-partner',
|
|
clickId: 'new-click',
|
|
})
|
|
})
|
|
|
|
it('should not update cookie when values have not changed', () => {
|
|
setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' })
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'same-partner',
|
|
ps_xid: 'same-click',
|
|
})
|
|
|
|
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
|
const { result } = renderHook(() => usePSInfo())
|
|
act(() => result.current.saveOrUpdate())
|
|
|
|
// Should not call set because values haven't changed
|
|
expect(cookieSetSpy).not.toHaveBeenCalled()
|
|
cookieSetSpy.mockRestore()
|
|
})
|
|
|
|
it('should not save to cookie when partner key is missing', () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_xid: 'click-only',
|
|
})
|
|
|
|
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
|
const { result } = renderHook(() => usePSInfo())
|
|
act(() => result.current.saveOrUpdate())
|
|
|
|
expect(cookieSetSpy).not.toHaveBeenCalled()
|
|
cookieSetSpy.mockRestore()
|
|
})
|
|
|
|
it('should not save to cookie when click ID is missing', () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'partner-only',
|
|
})
|
|
|
|
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
|
const { result } = renderHook(() => usePSInfo())
|
|
act(() => result.current.saveOrUpdate())
|
|
|
|
expect(cookieSetSpy).not.toHaveBeenCalled()
|
|
cookieSetSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
// ─── 3. Bind API Flow ──────────────────────────────────────────────────
|
|
describe('Bind API flow', () => {
|
|
it('should call mutateAsync with partnerKey and clickId on bind', async () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'bind-partner',
|
|
ps_xid: 'bind-click',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
await act(async () => {
|
|
await result.current.bind()
|
|
})
|
|
|
|
expect(mockMutateAsync).toHaveBeenCalledWith({
|
|
partnerKey: 'bind-partner',
|
|
clickId: 'bind-click',
|
|
})
|
|
})
|
|
|
|
it('should remove cookie after successful bind', async () => {
|
|
setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' })
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'rm-partner',
|
|
ps_xid: 'rm-click',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
await act(async () => {
|
|
await result.current.bind()
|
|
})
|
|
|
|
// Cookie should be removed after successful bind
|
|
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
|
})
|
|
|
|
it('should remove cookie on 400 error (already bound)', async () => {
|
|
mockMutateAsync.mockRejectedValue({ status: 400 })
|
|
setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' })
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'err-partner',
|
|
ps_xid: 'err-click',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
await act(async () => {
|
|
await result.current.bind()
|
|
})
|
|
|
|
// Cookie should be removed even on 400
|
|
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
|
})
|
|
|
|
it('should not remove cookie on non-400 errors', async () => {
|
|
mockMutateAsync.mockRejectedValue({ status: 500 })
|
|
setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' })
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'keep-partner',
|
|
ps_xid: 'keep-click',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
await act(async () => {
|
|
await result.current.bind()
|
|
})
|
|
|
|
// Cookie should still exist for non-400 errors
|
|
const cookieData = getCookieData()
|
|
expect(cookieData).toBeTruthy()
|
|
})
|
|
|
|
it('should not call bind when partner key is missing', async () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_xid: 'click-only',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
await act(async () => {
|
|
await result.current.bind()
|
|
})
|
|
|
|
expect(mockMutateAsync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should not call bind a second time (idempotency)', async () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'partner-once',
|
|
ps_xid: 'click-once',
|
|
})
|
|
|
|
const { result } = renderHook(() => usePSInfo())
|
|
|
|
// First bind
|
|
await act(async () => {
|
|
await result.current.bind()
|
|
})
|
|
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
|
|
|
// Second bind should be skipped (hasBind = true)
|
|
await act(async () => {
|
|
await result.current.bind()
|
|
})
|
|
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
|
|
describe('PartnerStack component mount behavior', () => {
|
|
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
|
|
mockSearchParams = new URLSearchParams({
|
|
ps_partner_key: 'mount-partner',
|
|
ps_xid: 'mount-click',
|
|
})
|
|
|
|
// Use lazy import so the mocks are applied
|
|
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
|
|
|
render(<PartnerStack />)
|
|
|
|
// The component calls saveOrUpdate and bind in useEffect
|
|
await waitFor(() => {
|
|
// Bind should have been called
|
|
expect(mockMutateAsync).toHaveBeenCalledWith({
|
|
partnerKey: 'mount-partner',
|
|
clickId: 'mount-click',
|
|
})
|
|
})
|
|
|
|
// Cookie should have been saved (saveOrUpdate was called before bind)
|
|
// After bind succeeds, cookie is removed
|
|
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
|
})
|
|
|
|
it('should render nothing (return null)', async () => {
|
|
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
|
|
|
const { container } = render(<PartnerStack />)
|
|
|
|
expect(container.innerHTML).toBe('')
|
|
})
|
|
})
|
|
})
|