Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## v5.1.0-dev
- Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
- Add Shopper Consents API support [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674)

## v5.0.0 (Feb 12, 2026)
- Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
Expand All @@ -14,7 +15,7 @@
## v4.2.0 (Nov 04, 2025)

- Upgrade to commerce-sdk-isomorphic v4.0.1 [#3449](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3449)
- Prevent headers from being overriden in `generateCustomEndpointOptions` [#3405](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3405/)
- Prevent headers from being overridden in `generateCustomEndpointOptions` [#3405](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3405/)

## v4.1.0 (Sep 25, 2025)

Expand Down
3 changes: 2 additions & 1 deletion packages/commerce-sdk-react/src/constant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023, Salesforce, Inc.
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
Expand Down Expand Up @@ -46,6 +46,7 @@ export const SERVER_AFFINITY_HEADER_KEY = 'sfdc_dwsid'

export const CLIENT_KEYS = {
SHOPPER_BASKETS: 'shopperBaskets',
SHOPPER_CONSENTS: 'shopperConsents',
SHOPPER_CONTEXTS: 'shopperContexts',
SHOPPER_CUSTOMERS: 'shopperCustomers',
SHOPPER_EXPERIENCE: 'shopperExperience',
Expand Down
29 changes: 29 additions & 0 deletions packages/commerce-sdk-react/src/hooks/ShopperConsents/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {ApiClients, CacheUpdateMatrix} from '../types'
import {getSubscriptions} from './queryKeyHelpers'
import {CLIENT_KEYS} from '../../constant'

const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS
type Client = NonNullable<ApiClients[typeof CLIENT_KEY]>

export const cacheUpdateMatrix: CacheUpdateMatrix<Client> = {
updateSubscription(customerId, {parameters}, response) {
// When a subscription is updated, we invalidate the getSubscriptions query
// to ensure the UI reflects the latest subscription state
return {
invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}]
}
},
updateSubscriptions(customerId, {parameters}, response) {
// When multiple subscriptions are updated, we invalidate the getSubscriptions query
// to ensure the UI reflects the latest subscription states
return {
invalidate: [{queryKey: getSubscriptions.queryKey(parameters)}]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {ShopperConsents} from 'commerce-sdk-isomorphic'
import {getUnimplementedEndpoints} from '../../test-utils'
import {cacheUpdateMatrix} from './cache'
import {ShopperConsentsMutations as mutations} from './mutation'
import * as queries from './query'

describe('Shopper Consents hooks', () => {
test('all endpoints have hooks', () => {
// unimplemented = SDK method exists, but no query hook or value in mutations enum
const unimplemented = getUnimplementedEndpoints(ShopperConsents, queries, mutations)
// If this test fails: create a new query hook, add the endpoint to the mutations enum,
// or add it to the `expected` array with a comment explaining "TODO" or "never" (and why).
expect(unimplemented).toEqual([])
})
test('all mutations have cache update logic', () => {
// unimplemented = value in mutations enum, but no method in cache update matrix
const unimplemented = new Set<string>(Object.values(mutations))
Object.entries(cacheUpdateMatrix).forEach(([method, implementation]) => {
if (implementation) unimplemented.delete(method)
})
// If this test fails: add cache update logic, remove the endpoint from the mutations enum,
// or add it to the `expected` array to indicate that it is still a TODO.
expect([...unimplemented]).toEqual([])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
export * from './mutation'
export * from './query'
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {act, waitFor} from '@testing-library/react'
import {ShopperConsentsTypes} from 'commerce-sdk-isomorphic'
import nock from 'nock'
import {
assertInvalidateQuery,
mockMutationEndpoints,
mockQueryEndpoint,
renderHookWithProviders,
waitAndExpectSuccess
} from '../../test-utils'
import {ShopperConsentsMutation, useShopperConsentsMutation} from '../ShopperConsents'
import {ApiClients, Argument} from '../types'
import * as queries from './query'
import {CLIENT_KEYS} from '../../constant'

jest.mock('../../auth/index.ts', () => {
const {default: mockAuth} = jest.requireActual('../../auth/index.ts')
mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'})
return mockAuth
})

const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONSENTS
type Client = NonNullable<ApiClients[typeof CLIENT_KEY]>
const consentsEndpoint = '/shopper/shopper-consents/'
/** All Shopper Consents parameters. Can be used for all endpoints, as unused params are ignored. */
const PARAMETERS = {
siteId: 'RefArch'
} as const
/** Options object that can be used for all query endpoints. */
const queryOptions = {parameters: PARAMETERS}

const baseSubscriptionResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
data: [
{
subscriptionId: 'test-subscription',
channels: ['email' as ShopperConsentsTypes.ChannelType],
contactPointValue: 'test@example.com'
}
]
}

type Mutation = ShopperConsentsMutation
/** Map of mutation method name to test options. */
type TestMap = {[Mut in Mutation]?: Argument<Client[Mut]>}
const testMap: TestMap = {
updateSubscription: {
parameters: PARAMETERS,
body: {
subscriptionId: 'test-subscription',
channel: 'email',
status: 'opt_in',
contactPointValue: 'test@example.com'
}
},
updateSubscriptions: {
parameters: PARAMETERS,
body: {
subscriptions: [
{
subscriptionId: 'test-subscription',
channel: 'email' as ShopperConsentsTypes.ChannelType,
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
contactPointValue: 'test@example.com'
}
]
}
}
}
// Type assertion is necessary because `Object.entries` is limited
type TestCase = [Mutation, NonNullable<TestMap[Mutation]>]
const testCases = Object.entries(testMap) as TestCase[]

describe('Shopper Consents mutations', () => {
beforeEach(() => nock.cleanAll())
test.each(testCases)('`%s` returns data on success', async (mutationName, options) => {
mockMutationEndpoints(consentsEndpoint, {})
const {result} = renderHookWithProviders(() => {
return useShopperConsentsMutation(mutationName)
})
act(() => result.current.mutate(options))
await waitAndExpectSuccess(() => result.current)
})
})

describe('Cache update behavior', () => {
describe('updateSubscription', () => {
beforeEach(() => nock.cleanAll())

test('invalidates `getSubscriptions` query', async () => {
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
mockMutationEndpoints(consentsEndpoint, {})
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
const {result} = renderHookWithProviders(() => {
return {
query: queries.useSubscriptions(queryOptions),
mutation: useShopperConsentsMutation('updateSubscription')
}
})
await waitAndExpectSuccess(() => result.current.query)
const oldData = result.current.query.data

act(() =>
result.current.mutation.mutate({
parameters: PARAMETERS,
body: {
subscriptionId: 'test-subscription',
channel: 'email',
status: 'opt_out',
contactPointValue: 'test@example.com'
}
})
)
await waitAndExpectSuccess(() => result.current.mutation)
assertInvalidateQuery(result.current.query, oldData)
})

test('triggers refetch after cache invalidation', async () => {
const updatedResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
data: [
{
subscriptionId: 'test-subscription',
channels: ['email' as ShopperConsentsTypes.ChannelType],
contactPointValue: 'test@example.com'
}
]
}

mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
mockMutationEndpoints(consentsEndpoint, {})
mockQueryEndpoint(consentsEndpoint, updatedResponse) // Refetch returns updated data

const {result} = renderHookWithProviders(() => {
return {
query: queries.useSubscriptions(queryOptions),
mutation: useShopperConsentsMutation('updateSubscription')
}
})
await waitAndExpectSuccess(() => result.current.query)
expect(result.current.query.data).toEqual(baseSubscriptionResponse)

act(() =>
result.current.mutation.mutate({
parameters: PARAMETERS,
body: {
subscriptionId: 'test-subscription',
channel: 'email',
status: 'opt_out',
contactPointValue: 'test@example.com'
}
})
)
await waitAndExpectSuccess(() => result.current.mutation)

// Wait for refetch to complete
await waitAndExpectSuccess(() => result.current.query)
// After refetch, data should be updated
expect(result.current.query.data).toEqual(updatedResponse)
})
})

describe('updateSubscriptions', () => {
beforeEach(() => nock.cleanAll())

test('invalidates `getSubscriptions` query', async () => {
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
mockMutationEndpoints(consentsEndpoint, {})
mockQueryEndpoint(consentsEndpoint, baseSubscriptionResponse)
const {result} = renderHookWithProviders(() => {
return {
query: queries.useSubscriptions(queryOptions),
mutation: useShopperConsentsMutation('updateSubscriptions')
}
})
await waitAndExpectSuccess(() => result.current.query)
const oldData = result.current.query.data

act(() =>
result.current.mutation.mutate({
parameters: PARAMETERS,
body: {
subscriptions: [
{
subscriptionId: 'test-subscription',
channel: 'email' as ShopperConsentsTypes.ChannelType,
status: 'opt_out' as ShopperConsentsTypes.ConsentStatus,
contactPointValue: 'test@example.com'
},
{
subscriptionId: 'test-subscription-2',
channel: 'sms' as ShopperConsentsTypes.ChannelType,
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
contactPointValue: '+15551234567'
}
]
}
})
)
await waitAndExpectSuccess(() => result.current.mutation)
assertInvalidateQuery(result.current.query, oldData)
})

test('ensures stale cache is not served after mutation', async () => {
const staleResponse = baseSubscriptionResponse
const freshResponse: ShopperConsentsTypes.ConsentSubscriptionResponse = {
data: [
{
subscriptionId: 'test-subscription',
channels: ['email' as ShopperConsentsTypes.ChannelType],
contactPointValue: 'test@example.com'
},
{
subscriptionId: 'test-subscription-2',
channels: ['sms' as ShopperConsentsTypes.ChannelType],
contactPointValue: '+15551234567'
}
]
}

mockQueryEndpoint(consentsEndpoint, staleResponse)
mockMutationEndpoints(consentsEndpoint, {})
mockQueryEndpoint(consentsEndpoint, freshResponse) // Refetch returns fresh data

const {result} = renderHookWithProviders(() => {
return {
query: queries.useSubscriptions(queryOptions),
mutation: useShopperConsentsMutation('updateSubscriptions')
}
})

// Initial fetch - get stale data
await waitAndExpectSuccess(() => result.current.query)
expect(result.current.query.data?.data).toHaveLength(1)

// Perform mutation
act(() =>
result.current.mutation.mutate({
parameters: PARAMETERS,
body: {
subscriptions: [
{
subscriptionId: 'test-subscription',
channel: 'email' as ShopperConsentsTypes.ChannelType,
status: 'opt_out' as ShopperConsentsTypes.ConsentStatus,
contactPointValue: 'test@example.com'
},
{
subscriptionId: 'test-subscription-2',
channel: 'sms' as ShopperConsentsTypes.ChannelType,
status: 'opt_in' as ShopperConsentsTypes.ConsentStatus,
contactPointValue: '+15551234567'
}
]
}
})
)
await waitAndExpectSuccess(() => result.current.mutation)

// Wait for refetch to complete with fresh data
await waitFor(() => {
expect(result.current.query.data?.data).toHaveLength(2)
})
expect(result.current.query.data).toEqual(freshResponse)
})
})
})
Loading
Loading