/** * 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>() 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) => { 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() // 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() expect(container.innerHTML).toBe('') }) }) })