import { act, renderHook } from '@testing-library/react' import { PARTNER_STACK_CONFIG } from '@/config' import usePSInfo from '../use-ps-info' let searchParamsValues: Record = {} const setSearchParams = (values: Record) => { searchParamsValues = values } type PartnerStackGlobal = typeof globalThis & { __partnerStackCookieMocks?: { get: ReturnType set: ReturnType remove: ReturnType } __partnerStackMutateAsync?: ReturnType } function getPartnerStackGlobal(): PartnerStackGlobal { return globalThis as PartnerStackGlobal } const ensureCookieMocks = () => { const globals = getPartnerStackGlobal() if (!globals.__partnerStackCookieMocks) throw new Error('Cookie mocks not initialized') return globals.__partnerStackCookieMocks } const ensureMutateAsync = () => { const globals = getPartnerStackGlobal() if (!globals.__partnerStackMutateAsync) throw new Error('Mutate mock not initialized') return globals.__partnerStackMutateAsync } vi.mock('js-cookie', () => { const get = vi.fn() const set = vi.fn() const remove = vi.fn() const globals = getPartnerStackGlobal() globals.__partnerStackCookieMocks = { get, set, remove } const cookieApi = { get, set, remove } return { default: cookieApi, get, set, remove, } }) vi.mock('@/next/navigation', () => ({ useSearchParams: () => ({ get: (key: string) => searchParamsValues[key] ?? null, }), })) vi.mock('@/service/use-billing', () => { const mutateAsync = vi.fn() const globals = getPartnerStackGlobal() globals.__partnerStackMutateAsync = mutateAsync return { useBindPartnerStackInfo: () => ({ mutateAsync, }), } }) describe('usePSInfo', () => { const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') beforeAll(() => { Object.defineProperty(globalThis, 'location', { value: { hostname: 'cloud.dify.ai' }, configurable: true, }) }) beforeEach(() => { setSearchParams({}) const { get, set, remove } = ensureCookieMocks() get.mockReset() set.mockReset() remove.mockReset() const mutate = ensureMutateAsync() mutate.mockReset() mutate.mockResolvedValue(undefined) get.mockReturnValue('{}') }) afterAll(() => { if (originalLocationDescriptor) Object.defineProperty(globalThis, 'location', originalLocationDescriptor) }) it('saves partner info when query params change', () => { const { get, set } = ensureCookieMocks() get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' })) setSearchParams({ ps_partner_key: 'new-partner', ps_xid: 'new-click', }) const { result } = renderHook(() => usePSInfo()) expect(result.current.psPartnerKey).toBe('new-partner') expect(result.current.psClickId).toBe('new-click') act(() => { result.current.saveOrUpdate() }) expect(set).toHaveBeenCalledWith( PARTNER_STACK_CONFIG.cookieName, JSON.stringify({ partnerKey: 'new-partner', clickId: 'new-click', }), { expires: PARTNER_STACK_CONFIG.saveCookieDays, path: '/', domain: '.dify.ai', }, ) }) it('does not overwrite cookie when params do not change', () => { setSearchParams({ ps_partner_key: 'existing', ps_xid: 'existing-click', }) const { get } = ensureCookieMocks() get.mockReturnValue(JSON.stringify({ partnerKey: 'existing', clickId: 'existing-click', })) const { result } = renderHook(() => usePSInfo()) act(() => { result.current.saveOrUpdate() }) const { set } = ensureCookieMocks() expect(set).not.toHaveBeenCalled() }) it('binds partner info and clears cookie once', async () => { setSearchParams({ ps_partner_key: 'bind-partner', ps_xid: 'bind-click', }) const { result } = renderHook(() => usePSInfo()) const mutate = ensureMutateAsync() const { remove } = ensureCookieMocks() await act(async () => { await result.current.bind() }) expect(mutate).toHaveBeenCalledWith({ partnerKey: 'bind-partner', clickId: 'bind-click', }) expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain: '.dify.ai', }) await act(async () => { await result.current.bind() }) expect(mutate).toHaveBeenCalledTimes(1) }) it('still removes cookie when bind fails with status 400', async () => { const mutate = ensureMutateAsync() mutate.mockRejectedValueOnce({ status: 400 }) setSearchParams({ ps_partner_key: 'bind-partner', ps_xid: 'bind-click', }) const { result } = renderHook(() => usePSInfo()) await act(async () => { await result.current.bind() }) const { remove } = ensureCookieMocks() expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain: '.dify.ai', }) }) // Cookie parse failure: covers catch block (L14-16) it('should fall back to empty object when cookie contains invalid JSON', () => { const { get } = ensureCookieMocks() get.mockReturnValue('not-valid-json{{{') const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) setSearchParams({ ps_partner_key: 'from-url', ps_xid: 'click-url', }) const { result } = renderHook(() => usePSInfo()) expect(consoleSpy).toHaveBeenCalledWith( 'Failed to parse partner stack info from cookie:', expect.any(SyntaxError), ) // Should still pick up values from search params expect(result.current.psPartnerKey).toBe('from-url') expect(result.current.psClickId).toBe('click-url') consoleSpy.mockRestore() }) // No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch) it('should not save or bind when neither search params nor cookie have keys', () => { const { get, set } = ensureCookieMocks() get.mockReturnValue('{}') setSearchParams({}) const { result } = renderHook(() => usePSInfo()) expect(result.current.psPartnerKey).toBeUndefined() expect(result.current.psClickId).toBeUndefined() act(() => { result.current.saveOrUpdate() }) expect(set).not.toHaveBeenCalled() }) it('should not call mutateAsync when keys are missing during bind', async () => { const { get } = ensureCookieMocks() get.mockReturnValue('{}') setSearchParams({}) const { result } = renderHook(() => usePSInfo()) const mutate = ensureMutateAsync() await act(async () => { await result.current.bind() }) expect(mutate).not.toHaveBeenCalled() }) // Non-400 error: covers L55 false branch (shouldRemoveCookie stays false) it('should not remove cookie when bind fails with non-400 error', async () => { const mutate = ensureMutateAsync() mutate.mockRejectedValueOnce({ status: 500 }) setSearchParams({ ps_partner_key: 'bind-partner', ps_xid: 'bind-click', }) const { result } = renderHook(() => usePSInfo()) await act(async () => { await result.current.bind() }) const { remove } = ensureCookieMocks() expect(remove).not.toHaveBeenCalled() }) // Fallback to cookie values: covers L19-20 right side of || operator it('should use cookie values when search params are absent', () => { const { get } = ensureCookieMocks() get.mockReturnValue(JSON.stringify({ partnerKey: 'cookie-partner', clickId: 'cookie-click', })) setSearchParams({}) const { result } = renderHook(() => usePSInfo()) expect(result.current.psPartnerKey).toBe('cookie-partner') expect(result.current.psClickId).toBe('cookie-click') }) // Partial key missing: only partnerKey present, no clickId it('should not save when only one key is available', () => { const { get, set } = ensureCookieMocks() get.mockReturnValue('{}') setSearchParams({ ps_partner_key: 'partial-key' }) const { result } = renderHook(() => usePSInfo()) act(() => { result.current.saveOrUpdate() }) expect(set).not.toHaveBeenCalled() }) })