Skip to content

Commit 5bdac1e

Browse files
@W-19143871 Marketing Consent Footer Email Capture component with Shopper Consents hooks.
1 parent f7847c5 commit 5bdac1e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+4306
-226
lines changed

packages/commerce-sdk-react/src/constant.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, Salesforce, Inc.
2+
* Copyright (c) 2025, Salesforce, Inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
@@ -46,6 +46,7 @@ export const SERVER_AFFINITY_HEADER_KEY = 'sfdc_dwsid'
4646

4747
export const CLIENT_KEYS = {
4848
SHOPPER_BASKETS: 'shopperBaskets',
49+
SHOPPER_CONSENTS: 'shopperConsents',
4950
SHOPPER_CONTEXTS: 'shopperContexts',
5051
SHOPPER_CUSTOMERS: 'shopperCustomers',
5152
SHOPPER_EXPERIENCE: 'shopperExperience',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {ApiClients, CacheUpdateMatrix} from '../types'
8+
import {getSubscriptions} from './queryKeyHelpers'
9+
import {CLIENT_KEYS} from '../../constant'
10+
11+
const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS
12+
type Client = NonNullable<ApiClients[typeof CLIENT_KEY]>
13+
14+
export const cacheUpdateMatrix: CacheUpdateMatrix<Client> = {
15+
updateSubscription(customerId, {parameters}, response) {
16+
// When a subscription is updated, we invalidate the getSubscriptions query
17+
// to ensure the UI reflects the latest subscription state
18+
return {
19+
invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}]
20+
}
21+
},
22+
updateSubscriptions(customerId, {parameters}, response) {
23+
// When multiple subscriptions are updated, we invalidate the getSubscriptions query
24+
// to ensure the UI reflects the latest subscription states
25+
return {
26+
invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}]
27+
}
28+
}
29+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {ShopperConsents} from 'commerce-sdk-isomorphic'
8+
import {getUnimplementedEndpoints} from '../../test-utils'
9+
import {cacheUpdateMatrix} from './cache'
10+
import {ShopperConsentsMutations as mutations} from './mutation'
11+
import * as queries from './query'
12+
13+
describe('Shopper Consents hooks', () => {
14+
test('all endpoints have hooks', () => {
15+
// unimplemented = SDK method exists, but no query hook or value in mutations enum
16+
const unimplemented = getUnimplementedEndpoints(ShopperConsents, queries, mutations)
17+
// If this test fails: create a new query hook, add the endpoint to the mutations enum,
18+
// or add it to the `expected` array with a comment explaining "TODO" or "never" (and why).
19+
expect(unimplemented).toEqual([])
20+
})
21+
test('all mutations have cache update logic', () => {
22+
// unimplemented = value in mutations enum, but no method in cache update matrix
23+
const unimplemented = new Set<string>(Object.values(mutations))
24+
Object.entries(cacheUpdateMatrix).forEach(([method, implementation]) => {
25+
if (implementation) unimplemented.delete(method)
26+
})
27+
// If this test fails: add cache update logic, remove the endpoint from the mutations enum,
28+
// or add it to the `expected` array to indicate that it is still a TODO.
29+
expect([...unimplemented]).toEqual([])
30+
})
31+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
export * from './mutation'
8+
export * from './query'
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import {act} from '@testing-library/react'
8+
import {ShopperConsentsTypes} from 'commerce-sdk-isomorphic'
9+
import nock from 'nock'
10+
import {
11+
assertInvalidateQuery,
12+
mockMutationEndpoints,
13+
mockQueryEndpoint,
14+
renderHookWithProviders,
15+
waitAndExpectSuccess
16+
} from '../../test-utils'
17+
import {ShopperConsentsMutation, useShopperConsentsMutation} from '../ShopperConsents'
18+
import {ApiClients, Argument} from '../types'
19+
import * as queries from './query'
20+
import {CLIENT_KEYS} from '../../constant'
21+
22+
jest.mock('../../auth/index.ts', () => {
23+
const {default: mockAuth} = jest.requireActual('../../auth/index.ts')
24+
mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'})
25+
return mockAuth
26+
})
27+
28+
const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS
29+
type Client = NonNullable<ApiClients[typeof CLIENT_KEY]>
30+
const consentsEndpoint = '/shopper/shopper-consents/'
31+
/** All Shopper Consents parameters. Can be used for all endpoints, as unused params are ignored. */
32+
const PARAMETERS = {
33+
siteId: 'RefArch'
34+
} as const
35+
/** Options object that can be used for all query endpoints. */
36+
const queryOptions = {parameters: PARAMETERS}
37+
38+
const baseSubscriptionResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
39+
data: [
40+
{
41+
subscriptionId: 'test-subscription',
42+
channels: ['email' as ShopperConsentsTypes.ChannelType],
43+
contactPointValue: 'test@example.com'
44+
}
45+
]
46+
}
47+
48+
type Mutation = ShopperConsentsMutation
49+
/** Map of mutation method name to test options. */
50+
type TestMap = {[Mut in Mutation]?: Argument<Client[Mut]>}
51+
const testMap: TestMap = {
52+
updateSubscription: {
53+
parameters: PARAMETERS,
54+
body: {
55+
subscriptionId: 'test-subscription',
56+
channel: 'email',
57+
status: 'opt_in',
58+
contactPointValue: 'test@example.com'
59+
}
60+
},
61+
updateSubscriptions: {
62+
parameters: PARAMETERS,
63+
body: {
64+
subscriptions: [
65+
{
66+
subscriptionId: 'test-subscription',
67+
channel: 'email' as ShopperConsentsTypes.ChannelType,
68+
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
69+
contactPointValue: 'test@example.com'
70+
}
71+
]
72+
}
73+
}
74+
}
75+
// Type assertion is necessary because `Object.entries` is limited
76+
type TestCase = [Mutation, NonNullable<TestMap[Mutation]>]
77+
const testCases = Object.entries(testMap) as TestCase[]
78+
79+
describe('Shopper Consents mutations', () => {
80+
beforeEach(() => nock.cleanAll())
81+
test.each(testCases)('`%s` returns data on success', async (mutationName, options) => {
82+
mockMutationEndpoints(consentsEndpoint, {})
83+
const {result} = renderHookWithProviders(() => {
84+
return useShopperConsentsMutation(mutationName)
85+
})
86+
act(() => result.current.mutate(options))
87+
await waitAndExpectSuccess(() => result.current)
88+
})
89+
})
90+
91+
describe('Cache update behavior', () => {
92+
describe('updateSubscription', () => {
93+
beforeEach(() => nock.cleanAll())
94+
95+
test('invalidates `getSubscriptions` query', async () => {
96+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
97+
mockMutationEndpoints(consentsEndpoint, {})
98+
const {result} = renderHookWithProviders(() => {
99+
return {
100+
query: queries.useSubscriptions(queryOptions),
101+
mutation: useShopperConsentsMutation('updateSubscription')
102+
}
103+
})
104+
await waitAndExpectSuccess(() => result.current.query)
105+
const oldData = result.current.query.data
106+
107+
act(() =>
108+
result.current.mutation.mutate({
109+
parameters: PARAMETERS,
110+
body: {
111+
subscriptionId: 'test-subscription',
112+
channel: 'email',
113+
status: 'opt_out',
114+
contactPointValue: 'test@example.com'
115+
}
116+
})
117+
)
118+
await waitAndExpectSuccess(() => result.current.mutation)
119+
assertInvalidateQuery(result.current.query, oldData)
120+
})
121+
122+
test('triggers refetch after cache invalidation', async () => {
123+
const updatedResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
124+
data: [
125+
{
126+
subscriptionId: 'test-subscription',
127+
channels: ['email' as ShopperConsentsTypes.ChannelType],
128+
contactPointValue: 'test@example.com'
129+
}
130+
]
131+
}
132+
133+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
134+
mockMutationEndpoints(consentsEndpoint, {})
135+
mockQueryEndpoint(consentsEndpoint, updatedResponse) // Refetch returns updated data
136+
137+
const {result} = renderHookWithProviders(() => {
138+
return {
139+
query: queries.useSubscriptions(queryOptions),
140+
mutation: useShopperConsentsMutation('updateSubscription')
141+
}
142+
})
143+
await waitAndExpectSuccess(() => result.current.query)
144+
expect(result.current.query.data).toEqual(baseSubscriptionResponse)
145+
146+
act(() =>
147+
result.current.mutation.mutate({
148+
parameters: PARAMETERS,
149+
body: {
150+
subscriptionId: 'test-subscription',
151+
channel: 'email',
152+
status: 'opt_out',
153+
contactPointValue: 'test@example.com'
154+
}
155+
})
156+
)
157+
await waitAndExpectSuccess(() => result.current.mutation)
158+
159+
// Wait for refetch to complete
160+
await waitAndExpectSuccess(() => result.current.query)
161+
// After refetch, data should be updated
162+
expect(result.current.query.data).toEqual(updatedResponse)
163+
})
164+
})
165+
166+
describe('updateSubscriptions', () => {
167+
beforeEach(() => nock.cleanAll())
168+
169+
test('invalidates `getSubscriptions` query', async () => {
170+
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
171+
mockMutationEndpoints(consentsEndpoint, {})
172+
const {result} = renderHookWithProviders(() => {
173+
return {
174+
query: queries.useSubscriptions(queryOptions),
175+
mutation: useShopperConsentsMutation('updateSubscriptions')
176+
}
177+
})
178+
await waitAndExpectSuccess(() => result.current.query)
179+
const oldData = result.current.query.data
180+
181+
act(() =>
182+
result.current.mutation.mutate({
183+
parameters: PARAMETERS,
184+
body: {
185+
subscriptions: [
186+
{
187+
subscriptionId: 'test-subscription',
188+
channel: 'email' as ShopperConsentsTypes.ChannelType,
189+
status: 'opt_out' as ShopperConsentsTypes.ConsentStatus,
190+
contactPointValue: 'test@example.com'
191+
},
192+
{
193+
subscriptionId: 'test-subscription-2',
194+
channel: 'sms' as ShopperConsentsTypes.ChannelType,
195+
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
196+
contactPointValue: '+15551234567'
197+
}
198+
]
199+
}
200+
})
201+
)
202+
await waitAndExpectSuccess(() => result.current.mutation)
203+
assertInvalidateQuery(result.current.query, oldData)
204+
})
205+
206+
test('ensures stale cache is not served after mutation', async () => {
207+
const staleResponse = baseSubscriptionResponse
208+
const freshResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
209+
data: [
210+
{
211+
subscriptionId: 'test-subscription',
212+
channels: ['email' as ShopperConsentsTypes.ChannelType],
213+
contactPointValue: 'test@example.com'
214+
},
215+
{
216+
subscriptionId: 'test-subscription-2',
217+
channels: ['sms' as ShopperConsentsTypes.ChannelType],
218+
contactPointValue: '+15551234567'
219+
}
220+
]
221+
}
222+
223+
mockQueryEndpoint(consentsEndpoint, staleResponse)
224+
mockMutationEndpoints(consentsEndpoint, {})
225+
mockQueryEndpoint(consentsEndpoint, freshResponse) // Refetch returns fresh data
226+
227+
const {result} = renderHookWithProviders(() => {
228+
return {
229+
query: queries.useSubscriptions(queryOptions),
230+
mutation: useShopperConsentsMutation('updateSubscriptions')
231+
}
232+
})
233+
234+
// Initial fetch - get stale data
235+
await waitAndExpectSuccess(() => result.current.query)
236+
expect(result.current.query.data?.data).toHaveLength(1)
237+
238+
// Perform mutation
239+
act(() =>
240+
result.current.mutation.mutate({
241+
parameters: PARAMETERS,
242+
body: {
243+
subscriptions: [
244+
{
245+
subscriptionId: 'test-subscription',
246+
channel: 'email' as ShopperConsentsTypes.ChannelType,
247+
status: 'opt_out' as ShopperConsentsTypes.ConsentStatus,
248+
contactPointValue: 'test@example.com'
249+
},
250+
{
251+
subscriptionId: 'test-subscription-2',
252+
channel: 'sms' as ShopperConsentsTypes.ChannelType,
253+
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
254+
contactPointValue: '+15551234567'
255+
}
256+
]
257+
}
258+
})
259+
)
260+
await waitAndExpectSuccess(() => result.current.mutation)
261+
262+
// Wait for automatic refetch
263+
await waitAndExpectSuccess(() => result.current.query)
264+
// Should have fresh data, not stale
265+
expect(result.current.query.data?.data).toHaveLength(2)
266+
expect(result.current.query.data).toEqual(freshResponse)
267+
})
268+
})
269+
})

0 commit comments

Comments
 (0)