-
Notifications
You must be signed in to change notification settings - Fork 303
fix(quests): track shared post clicks from post page #6644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| import { renderHook, waitFor } from '@testing-library/react'; | ||
| import type { NextRouter } from 'next/router'; | ||
| import { useRouter } from 'next/router'; | ||
| import { useAuthContext } from '../contexts/AuthContext'; | ||
| import { trackSharedPostClick } from '../graphql/quests'; | ||
| import { ReferralCampaignKey } from '../lib/referral'; | ||
| import { | ||
| getShareLinkClickKey, | ||
| isShareLinkClickCampaign, | ||
| shouldTrackShareLinkClick, | ||
| useShareLinkClick, | ||
| } from './useShareLinkClick'; | ||
|
|
||
| jest.mock('next/router', () => ({ | ||
| useRouter: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('../contexts/AuthContext', () => ({ | ||
| useAuthContext: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('../graphql/quests', () => ({ | ||
| ...jest.requireActual('../graphql/quests'), | ||
| trackSharedPostClick: jest.fn(), | ||
| })); | ||
|
|
||
| const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>; | ||
| const mockUseAuthContext = useAuthContext as jest.MockedFunction< | ||
| typeof useAuthContext | ||
| >; | ||
| const mockTrackSharedPostClick = trackSharedPostClick as jest.MockedFunction< | ||
| typeof trackSharedPostClick | ||
| >; | ||
|
|
||
| const postId = 'post-1'; | ||
| const referringUserId = 'sharer-1'; | ||
|
|
||
| const setRouterQuery = (query: NextRouter['query']) => { | ||
| mockUseRouter.mockReturnValue({ | ||
| query, | ||
| } as unknown as NextRouter); | ||
| }; | ||
|
|
||
| const setViewer = ({ | ||
| userId, | ||
| isAuthReady = true, | ||
| }: { | ||
| userId?: string | null; | ||
| isAuthReady?: boolean; | ||
| } = {}) => { | ||
| mockUseAuthContext.mockReturnValue({ | ||
| user: userId ? { id: userId } : null, | ||
| isAuthReady, | ||
| } as unknown as ReturnType<typeof useAuthContext>); | ||
| }; | ||
|
|
||
| describe('share link click helpers', () => { | ||
| it('should identify click campaigns and build stable keys', () => { | ||
| expect(isShareLinkClickCampaign(ReferralCampaignKey.SharePost)).toBe(true); | ||
| expect(isShareLinkClickCampaign(ReferralCampaignKey.ShareSlack)).toBe(true); | ||
| expect(isShareLinkClickCampaign(ReferralCampaignKey.ShareComment)).toBe( | ||
| false, | ||
| ); | ||
| expect( | ||
| getShareLinkClickKey({ | ||
| referringUserId, | ||
| postId, | ||
| campaign: ReferralCampaignKey.SharePost, | ||
| }), | ||
| ).toBe(`${referringUserId}:${postId}:${ReferralCampaignKey.SharePost}`); | ||
| }); | ||
|
|
||
| it('should reject incomplete and self-click attribution', () => { | ||
| expect( | ||
| shouldTrackShareLinkClick({ | ||
| campaign: ReferralCampaignKey.SharePost, | ||
| referringUserId, | ||
| postId, | ||
| userId: 'visitor-1', | ||
| }), | ||
| ).toBe(true); | ||
| expect( | ||
| shouldTrackShareLinkClick({ | ||
| campaign: ReferralCampaignKey.SharePost, | ||
| referringUserId, | ||
| postId, | ||
| userId: referringUserId, | ||
| }), | ||
| ).toBe(false); | ||
| expect( | ||
| shouldTrackShareLinkClick({ | ||
| campaign: ReferralCampaignKey.ShareProfile, | ||
| referringUserId, | ||
| postId, | ||
| }), | ||
| ).toBe(false); | ||
| expect( | ||
| shouldTrackShareLinkClick({ | ||
| campaign: ReferralCampaignKey.SharePost, | ||
| postId, | ||
| }), | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('useShareLinkClick', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| setViewer(); | ||
| setRouterQuery({ | ||
| cid: ReferralCampaignKey.SharePost, | ||
| userid: referringUserId, | ||
| }); | ||
| mockTrackSharedPostClick.mockResolvedValue(undefined); | ||
| }); | ||
|
|
||
| it('should track an eligible anonymous post share click once', async () => { | ||
| const { rerender } = renderHook( | ||
| (props: { postId?: string }) => useShareLinkClick(props), | ||
| { | ||
| initialProps: { postId }, | ||
| }, | ||
| ); | ||
|
|
||
| await waitFor(() => { | ||
| expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| expect(mockTrackSharedPostClick).toHaveBeenCalledWith({ | ||
| referringUserId, | ||
| postId, | ||
| campaign: ReferralCampaignKey.SharePost, | ||
| }); | ||
|
|
||
| rerender({ postId }); | ||
|
|
||
| expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('should use the first query param value and ignore unrelated route changes', async () => { | ||
| const { rerender } = renderHook( | ||
| (props: { postId?: string }) => useShareLinkClick(props), | ||
| { | ||
| initialProps: { postId }, | ||
| }, | ||
| ); | ||
|
|
||
| await waitFor(() => { | ||
| expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| setRouterQuery({ | ||
| cid: [ReferralCampaignKey.SharePost, ReferralCampaignKey.ShareProfile], | ||
| userid: [referringUserId, 'other-sharer'], | ||
| unrelated: '1', | ||
| }); | ||
| rerender({ postId }); | ||
|
|
||
| expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('should skip self-clicks', () => { | ||
| setViewer({ userId: referringUserId }); | ||
|
|
||
| renderHook(() => useShareLinkClick({ postId })); | ||
|
|
||
| expect(mockTrackSharedPostClick).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should skip when attribution is incomplete or auth is not ready', () => { | ||
| setRouterQuery({ userid: referringUserId }); | ||
| renderHook(() => useShareLinkClick({ postId })); | ||
|
|
||
| setRouterQuery({ | ||
| cid: ReferralCampaignKey.SharePost, | ||
| userid: referringUserId, | ||
| }); | ||
| renderHook(() => useShareLinkClick({})); | ||
|
|
||
| setViewer({ isAuthReady: false }); | ||
| renderHook(() => useShareLinkClick({ postId })); | ||
|
|
||
| expect(mockTrackSharedPostClick).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should not retry after a failed tracking request on rerender', async () => { | ||
| mockTrackSharedPostClick.mockRejectedValue(new Error('network error')); | ||
|
|
||
| const { rerender } = renderHook( | ||
| (props: { postId?: string }) => useShareLinkClick(props), | ||
| { | ||
| initialProps: { postId }, | ||
| }, | ||
| ); | ||
|
|
||
| await waitFor(() => { | ||
| expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| rerender({ postId }); | ||
|
|
||
| expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { useEffect, useRef } from 'react'; | ||
| import { useRouter } from 'next/router'; | ||
| import { useAuthContext } from '../contexts/AuthContext'; | ||
| import { trackSharedPostClick } from '../graphql/quests'; | ||
| import { getFirstQueryParam } from '../lib/func'; | ||
| import { ReferralCampaignKey } from '../lib/referral'; | ||
|
|
||
| const SHARE_LINK_CLICK_CAMPAIGNS = new Set<string>([ | ||
| ReferralCampaignKey.SharePost, | ||
| ReferralCampaignKey.ShareSlack, | ||
| ]); | ||
|
|
||
| export const isShareLinkClickCampaign = ( | ||
| campaign?: string | null, | ||
| ): campaign is ReferralCampaignKey => | ||
| !!campaign && SHARE_LINK_CLICK_CAMPAIGNS.has(campaign); | ||
|
|
||
| interface UseShareLinkClickProps { | ||
| postId?: string | null; | ||
| enabled?: boolean; | ||
| } | ||
|
|
||
| export const getShareLinkClickKey = ({ | ||
| referringUserId, | ||
| postId, | ||
| campaign, | ||
| }: { | ||
| referringUserId: string; | ||
| postId: string; | ||
| campaign: string; | ||
| }): string => `${referringUserId}:${postId}:${campaign}`; | ||
|
|
||
| export const shouldTrackShareLinkClick = ({ | ||
| campaign, | ||
| referringUserId, | ||
| postId, | ||
| userId, | ||
| }: { | ||
| campaign?: string | null; | ||
| referringUserId?: string | null; | ||
| postId?: string | null; | ||
| userId?: string | null; | ||
| }): boolean => | ||
| isShareLinkClickCampaign(campaign) && | ||
| !!referringUserId && | ||
| !!postId && | ||
| referringUserId !== userId; | ||
|
|
||
| export const useShareLinkClick = ({ | ||
| postId, | ||
| enabled = true, | ||
| }: UseShareLinkClickProps): void => { | ||
| const { user, isAuthReady } = useAuthContext(); | ||
| const router = useRouter(); | ||
| const trackedKeysRef = useRef(new Set<string>()); | ||
| const campaign = getFirstQueryParam(router.query.cid); | ||
| const referringUserId = getFirstQueryParam(router.query.userid); | ||
|
|
||
| useEffect(() => { | ||
| if ( | ||
| !enabled || | ||
| !isAuthReady || | ||
| !isShareLinkClickCampaign(campaign) || | ||
| !referringUserId || | ||
| !postId || | ||
| referringUserId === user?.id | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: Reviewed by AI. |
||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| const clickKey = getShareLinkClickKey({ | ||
| referringUserId, | ||
| postId, | ||
| campaign, | ||
| }); | ||
|
|
||
| if (trackedKeysRef.current.has(clickKey)) { | ||
| return; | ||
| } | ||
|
|
||
| trackedKeysRef.current.add(clickKey); | ||
|
|
||
| trackSharedPostClick({ | ||
| referringUserId, | ||
| postId, | ||
| campaign, | ||
| }).catch(() => undefined); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: Reviewed by AI. |
||
| }, [campaign, enabled, isAuthReady, postId, referringUserId, user?.id]); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking:
enabledhas no caller — the only mount passespostIdalone — and no test covers it. Please drop it until a surface actually needs to switch the hook off.Reviewed by AI.