Skip to content

Commit 237ee93

Browse files
VGR-GITclaude
andcommitted
feat(rewards): campaign opt-in and participant status
- Adds `CampaignParticipantStatusDto` and `CampaignParticipantStatusState` types - Adds `RewardsDataService.optInToCampaign` (POST /campaigns/:id/opt-in) and `getCampaignParticipantStatus` (GET /campaigns/:campaignId/status) - Adds `RewardsController.optInToCampaign` (feature-flag gated, no cache) and `getCampaignParticipantStatus` (5-min cache via wrapWithCache) - Wires new actions through rewards-controller-messenger - Adds `useOptInToCampaign` hook and `useGetCampaignParticipantStatus` hook, both short-circuit when `rewardsCampaignsEnabled` flag is off - Updates state snapshots and initial-background-state for new `campaignParticipantStatus` state field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 39ec56e commit 237ee93

12 files changed

Lines changed: 996 additions & 5 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { renderHook, act } from '@testing-library/react-hooks';
2+
import { useSelector } from 'react-redux';
3+
import { useGetCampaignParticipantStatus } from './useGetCampaignParticipantStatus';
4+
import Engine from '../../../../core/Engine';
5+
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
6+
import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
7+
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
8+
9+
jest.mock('react-redux', () => ({
10+
useSelector: jest.fn(),
11+
}));
12+
13+
jest.mock('../../../../core/Engine', () => ({
14+
controllerMessenger: { call: jest.fn() },
15+
}));
16+
17+
jest.mock('./useInvalidateByRewardEvents', () => ({
18+
useInvalidateByRewardEvents: jest.fn(),
19+
}));
20+
21+
jest.mock('../../../../selectors/rewards', () => ({
22+
selectRewardsSubscriptionId: jest.fn(),
23+
}));
24+
25+
jest.mock('../../../../selectors/featureFlagController/rewards', () => ({
26+
selectCampaignsRewardsEnabledFlag: jest.fn(),
27+
}));
28+
29+
const mockCall = Engine.controllerMessenger.call as jest.MockedFunction<
30+
typeof Engine.controllerMessenger.call
31+
>;
32+
const mockUseInvalidateByRewardEvents =
33+
useInvalidateByRewardEvents as jest.MockedFunction<
34+
typeof useInvalidateByRewardEvents
35+
>;
36+
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
37+
38+
const SUB_ID = 'sub-123';
39+
const CAMPAIGN_ID = 'camp-456';
40+
const STATUS = { optedIn: true };
41+
42+
function setupSelectors(
43+
subscriptionId: string | null,
44+
campaignsEnabled: boolean,
45+
) {
46+
mockUseSelector.mockImplementation((selector) => {
47+
if (selector === selectRewardsSubscriptionId) return subscriptionId;
48+
if (selector === selectCampaignsRewardsEnabledFlag) return campaignsEnabled;
49+
return undefined;
50+
});
51+
}
52+
53+
describe('useGetCampaignParticipantStatus', () => {
54+
beforeEach(() => {
55+
jest.clearAllMocks();
56+
});
57+
58+
it('skips fetch and returns null status when feature flag is disabled', async () => {
59+
setupSelectors(SUB_ID, false);
60+
const { result } = renderHook(() =>
61+
useGetCampaignParticipantStatus(CAMPAIGN_ID),
62+
);
63+
await act(async () => {
64+
await Promise.resolve();
65+
});
66+
expect(mockCall).not.toHaveBeenCalled();
67+
expect(result.current.status).toBeNull();
68+
});
69+
70+
it('fetches and returns status on mount', async () => {
71+
setupSelectors(SUB_ID, true);
72+
mockCall.mockResolvedValueOnce(STATUS as never);
73+
74+
const { result, waitForNextUpdate } = renderHook(() =>
75+
useGetCampaignParticipantStatus(CAMPAIGN_ID),
76+
);
77+
await act(async () => {
78+
await waitForNextUpdate();
79+
});
80+
81+
expect(mockCall).toHaveBeenCalledWith(
82+
'RewardsController:getCampaignParticipantStatus',
83+
CAMPAIGN_ID,
84+
SUB_ID,
85+
);
86+
expect(result.current.status).toEqual(STATUS);
87+
expect(result.current.isLoading).toBe(false);
88+
expect(result.current.hasError).toBe(false);
89+
});
90+
91+
it('sets hasError on failure', async () => {
92+
setupSelectors(SUB_ID, true);
93+
mockCall.mockRejectedValueOnce(new Error('fail') as never);
94+
95+
const { result, waitForNextUpdate } = renderHook(() =>
96+
useGetCampaignParticipantStatus(CAMPAIGN_ID),
97+
);
98+
await act(async () => {
99+
await waitForNextUpdate();
100+
});
101+
102+
expect(result.current.hasError).toBe(true);
103+
expect(result.current.status).toBeNull();
104+
});
105+
106+
it('subscribes to RewardsController:campaignOptedIn to auto-refetch', () => {
107+
setupSelectors(SUB_ID, true);
108+
mockCall.mockResolvedValue({ optedIn: false } as never);
109+
110+
renderHook(() => useGetCampaignParticipantStatus(CAMPAIGN_ID));
111+
112+
expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith(
113+
expect.arrayContaining(['RewardsController:campaignOptedIn']),
114+
expect.any(Function),
115+
);
116+
});
117+
118+
it('allows manual refetch', async () => {
119+
setupSelectors(SUB_ID, true);
120+
mockCall
121+
.mockResolvedValueOnce({ optedIn: false } as never)
122+
.mockResolvedValueOnce(STATUS as never);
123+
124+
const { result, waitForNextUpdate } = renderHook(() =>
125+
useGetCampaignParticipantStatus(CAMPAIGN_ID),
126+
);
127+
await act(async () => {
128+
await waitForNextUpdate();
129+
});
130+
expect(result.current.status).toEqual({ optedIn: false });
131+
132+
await act(async () => {
133+
result.current.refetch();
134+
await waitForNextUpdate();
135+
});
136+
expect(result.current.status).toEqual(STATUS);
137+
});
138+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useCallback, useEffect, useMemo, useState } from 'react';
2+
import { useSelector } from 'react-redux';
3+
import Engine from '../../../../core/Engine';
4+
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
5+
import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
6+
import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types';
7+
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
8+
9+
export interface UseGetCampaignParticipantStatusResult {
10+
/** Participant status, or null when flag is disabled / not yet loaded */
11+
status: CampaignParticipantStatusDto | null;
12+
/** Whether the status is being fetched */
13+
isLoading: boolean;
14+
/** Whether there was an error fetching the status */
15+
hasError: boolean;
16+
/** Manually re-fetch the status (also invalidates the cache via controller TTL) */
17+
refetch: () => Promise<void>;
18+
}
19+
20+
/**
21+
* Hook to fetch the campaign participant status for the current subscription.
22+
* Returns null status and skips the API call when the campaigns feature flag is off.
23+
* Results are cached for 5 minutes by the RewardsController.
24+
*/
25+
export const useGetCampaignParticipantStatus = (
26+
campaignId: string | undefined,
27+
): UseGetCampaignParticipantStatusResult => {
28+
const subscriptionId = useSelector(selectRewardsSubscriptionId);
29+
const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag);
30+
const [status, setStatus] = useState<CampaignParticipantStatusDto | null>(
31+
null,
32+
);
33+
const [isLoading, setIsLoading] = useState(false);
34+
const [hasError, setHasError] = useState(false);
35+
36+
const fetchStatus = useCallback(async (): Promise<void> => {
37+
if (!isCampaignsEnabled || !subscriptionId || !campaignId) {
38+
setStatus(null);
39+
return;
40+
}
41+
42+
try {
43+
setIsLoading(true);
44+
setHasError(false);
45+
const result = await Engine.controllerMessenger.call(
46+
'RewardsController:getCampaignParticipantStatus',
47+
campaignId,
48+
subscriptionId,
49+
);
50+
setStatus(result);
51+
} catch {
52+
setHasError(true);
53+
} finally {
54+
setIsLoading(false);
55+
}
56+
}, [subscriptionId, isCampaignsEnabled, campaignId]);
57+
58+
useEffect(() => {
59+
fetchStatus();
60+
}, [fetchStatus]);
61+
62+
// Refetch whenever a successful opt-in invalidates the cached status
63+
const campaignOptedInEvents = useMemo(
64+
() => ['RewardsController:campaignOptedIn'] as const,
65+
[],
66+
);
67+
useInvalidateByRewardEvents(campaignOptedInEvents, fetchStatus);
68+
69+
return { status, isLoading, hasError, refetch: fetchStatus };
70+
};
71+
72+
export default useGetCampaignParticipantStatus;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { renderHook, act } from '@testing-library/react-hooks';
2+
import { useSelector } from 'react-redux';
3+
import { useOptInToCampaign } from './useOptInToCampaign';
4+
import Engine from '../../../../core/Engine';
5+
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
6+
import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
7+
8+
jest.mock('react-redux', () => ({
9+
useSelector: jest.fn(),
10+
}));
11+
12+
jest.mock('../../../../core/Engine', () => ({
13+
controllerMessenger: { call: jest.fn() },
14+
}));
15+
16+
jest.mock('../../../../selectors/rewards', () => ({
17+
selectRewardsSubscriptionId: jest.fn(),
18+
}));
19+
20+
jest.mock('../../../../selectors/featureFlagController/rewards', () => ({
21+
selectCampaignsRewardsEnabledFlag: jest.fn(),
22+
}));
23+
24+
const mockCall = Engine.controllerMessenger.call as jest.MockedFunction<
25+
typeof Engine.controllerMessenger.call
26+
>;
27+
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
28+
29+
const SUB_ID = 'sub-123';
30+
const CAMPAIGN_ID = 'camp-456';
31+
const STATUS = { optedIn: true };
32+
33+
function setupSelectors(
34+
subscriptionId: string | null,
35+
campaignsEnabled: boolean,
36+
) {
37+
mockUseSelector.mockImplementation((selector) => {
38+
if (selector === selectRewardsSubscriptionId) return subscriptionId;
39+
if (selector === selectCampaignsRewardsEnabledFlag) return campaignsEnabled;
40+
return undefined;
41+
});
42+
}
43+
44+
describe('useOptInToCampaign', () => {
45+
beforeEach(() => {
46+
jest.clearAllMocks();
47+
});
48+
49+
it('returns null when feature flag is disabled', async () => {
50+
setupSelectors(SUB_ID, false);
51+
const { result } = renderHook(() => useOptInToCampaign());
52+
let returnValue;
53+
await act(async () => {
54+
returnValue = await result.current.optInToCampaign(CAMPAIGN_ID);
55+
});
56+
expect(returnValue).toBeNull();
57+
expect(mockCall).not.toHaveBeenCalled();
58+
});
59+
60+
it('returns null when subscriptionId is missing', async () => {
61+
setupSelectors(null, true);
62+
const { result } = renderHook(() => useOptInToCampaign());
63+
let returnValue;
64+
await act(async () => {
65+
returnValue = await result.current.optInToCampaign(CAMPAIGN_ID);
66+
});
67+
expect(returnValue).toBeNull();
68+
expect(mockCall).not.toHaveBeenCalled();
69+
});
70+
71+
it('calls the controller and returns status on success', async () => {
72+
setupSelectors(SUB_ID, true);
73+
mockCall.mockResolvedValueOnce(STATUS as never);
74+
75+
const { result } = renderHook(() => useOptInToCampaign());
76+
let returnValue;
77+
await act(async () => {
78+
returnValue = await result.current.optInToCampaign(CAMPAIGN_ID);
79+
});
80+
81+
expect(mockCall).toHaveBeenCalledWith(
82+
'RewardsController:optInToCampaign',
83+
CAMPAIGN_ID,
84+
SUB_ID,
85+
);
86+
expect(returnValue).toEqual(STATUS);
87+
expect(result.current.isOptingIn).toBe(false);
88+
expect(result.current.optInError).toBeUndefined();
89+
});
90+
91+
it('sets optInError and rethrows on failure', async () => {
92+
setupSelectors(SUB_ID, true);
93+
mockCall.mockRejectedValueOnce(new Error('Network error') as never);
94+
95+
const { result } = renderHook(() => useOptInToCampaign());
96+
await act(async () => {
97+
await expect(result.current.optInToCampaign(CAMPAIGN_ID)).rejects.toThrow(
98+
'Network error',
99+
);
100+
});
101+
102+
expect(result.current.optInError).toBe('Network error');
103+
expect(result.current.isOptingIn).toBe(false);
104+
});
105+
106+
it('clears optInError when clearOptInError is called', async () => {
107+
setupSelectors(SUB_ID, true);
108+
mockCall.mockRejectedValueOnce(new Error('err') as never);
109+
110+
const { result } = renderHook(() => useOptInToCampaign());
111+
await act(async () => {
112+
await expect(
113+
result.current.optInToCampaign(CAMPAIGN_ID),
114+
).rejects.toThrow();
115+
});
116+
expect(result.current.optInError).toBeDefined();
117+
118+
act(() => result.current.clearOptInError());
119+
expect(result.current.optInError).toBeUndefined();
120+
});
121+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useCallback, useState } from 'react';
2+
import { useSelector } from 'react-redux';
3+
import Engine from '../../../../core/Engine';
4+
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
5+
import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
6+
import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types';
7+
8+
export interface UseOptInToCampaignResult {
9+
/** Opt the current subscription into a campaign */
10+
optInToCampaign: (
11+
campaignId: string,
12+
) => Promise<CampaignParticipantStatusDto | null>;
13+
/** Whether opt-in is in progress */
14+
isOptingIn: boolean;
15+
/** Error message if opt-in failed */
16+
optInError: string | undefined;
17+
/** Clear the opt-in error */
18+
clearOptInError: () => void;
19+
}
20+
21+
/**
22+
* Hook to opt the current subscription into a campaign.
23+
* Returns null immediately when the campaigns feature flag is disabled.
24+
*/
25+
export const useOptInToCampaign = (): UseOptInToCampaignResult => {
26+
const subscriptionId = useSelector(selectRewardsSubscriptionId);
27+
const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag);
28+
const [isOptingIn, setIsOptingIn] = useState(false);
29+
const [optInError, setOptInError] = useState<string | undefined>(undefined);
30+
31+
const optInToCampaign = useCallback(
32+
async (
33+
campaignId: string,
34+
): Promise<CampaignParticipantStatusDto | null> => {
35+
if (!isCampaignsEnabled || !subscriptionId) {
36+
return null;
37+
}
38+
39+
try {
40+
setIsOptingIn(true);
41+
setOptInError(undefined);
42+
return await Engine.controllerMessenger.call(
43+
'RewardsController:optInToCampaign',
44+
campaignId,
45+
subscriptionId,
46+
);
47+
} catch (error) {
48+
const message =
49+
error instanceof Error ? error.message : 'Opt-in failed';
50+
setOptInError(message);
51+
throw error;
52+
} finally {
53+
setIsOptingIn(false);
54+
}
55+
},
56+
[subscriptionId, isCampaignsEnabled],
57+
);
58+
59+
const clearOptInError = useCallback(() => setOptInError(undefined), []);
60+
61+
return { optInToCampaign, isOptingIn, optInError, clearOptInError };
62+
};
63+
64+
export default useOptInToCampaign;

0 commit comments

Comments
 (0)