Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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,269 @@
/*
* 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} 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, {})
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, {})
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 automatic refetch
await waitAndExpectSuccess(() => result.current.query)
// Should have fresh data, not stale
expect(result.current.query.data?.data).toHaveLength(2)
expect(result.current.query.data).toEqual(freshResponse)
})
})
})
Loading
Loading