Skip to content

Commit e518f4d

Browse files
@W-19143871 Handle 207 multi-status error responses for marketing consent.
1 parent e55a8a1 commit e518f4d

File tree

4 files changed

+293
-3
lines changed

4 files changed

+293
-3
lines changed

packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export const useEmailSubscription = ({tag} = {}) => {
132132
subscriptionUpdates.map((s) => s.subscriptionId)
133133
)
134134

135-
// Submit the consent using bulk API (ShopperConsents API v1.1.3)
135+
// Submit the consent using Shopper Consents Bulk API
136136
await updateSubscriptions(subscriptionUpdates)
137137

138138
setMessage(messages.success_confirmation)

packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,81 @@ describe('useEmailSubscription', () => {
501501
})
502502
})
503503

504+
describe('submit action - 207 Multi-Status (error thrown by useMarketingConsent)', () => {
505+
test('shows error when bulk update rejects due to per-item failures', async () => {
506+
const bulkError = new Error('1 of 1 subscription update(s) failed.')
507+
bulkError.failures = [
508+
{
509+
subscriptionId: 'weekly-newsletter',
510+
success: false,
511+
error: {code: 'UPDATE_FAILED', message: 'Failed'}
512+
}
513+
]
514+
mockUpdateSubscriptions.mockRejectedValue(bulkError)
515+
const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
516+
wrapper: createWrapper()
517+
})
518+
519+
act(() => {
520+
result.current.actions.setEmail('test@example.com')
521+
})
522+
523+
await act(async () => {
524+
await result.current.actions.submit()
525+
})
526+
527+
await waitFor(() => {
528+
expect(result.current.state.feedback.type).toBe('error')
529+
expect(result.current.state.feedback.message).toBe(
530+
"We couldn't process the subscription. Try again."
531+
)
532+
})
533+
})
534+
535+
test('does not clear email field on 207 failure', async () => {
536+
const bulkError = new Error('1 of 1 subscription update(s) failed.')
537+
mockUpdateSubscriptions.mockRejectedValue(bulkError)
538+
const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
539+
wrapper: createWrapper()
540+
})
541+
542+
act(() => {
543+
result.current.actions.setEmail('test@example.com')
544+
})
545+
546+
await act(async () => {
547+
await result.current.actions.submit()
548+
})
549+
550+
await waitFor(() => {
551+
expect(result.current.state.email).toBe('test@example.com')
552+
})
553+
})
554+
555+
test('logs the thrown error to console', async () => {
556+
const bulkError = new Error('1 of 2 subscription update(s) failed.')
557+
mockUpdateSubscriptions.mockRejectedValue(bulkError)
558+
const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
559+
wrapper: createWrapper()
560+
})
561+
562+
act(() => {
563+
result.current.actions.setEmail('test@example.com')
564+
})
565+
566+
await act(async () => {
567+
await result.current.actions.submit()
568+
})
569+
570+
await waitFor(() => {
571+
expect(console.error).toHaveBeenCalledWith(
572+
'[useEmailSubscription] Subscription error:',
573+
bulkError
574+
)
575+
})
576+
})
577+
})
578+
504579
describe('Loading states', () => {
505580
test('reflects isUpdating state from useMarketingConsent', () => {
506581
useMarketingConsent.mockReturnValue({

packages/template-retail-react-app/app/hooks/use-marketing-consent.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,17 +207,44 @@ export const useMarketingConsent = ({enabled = true, tags = [], expand} = {}) =>
207207
}
208208

209209
/**
210-
* Update multiple consent subscriptions in bulk
210+
* Update multiple consent subscriptions in bulk.
211+
* The bulk endpoint may return HTTP 207 Multi-Status where individual items can fail
212+
* even though the HTTP request itself succeeds. This function inspects the response
213+
* and throws if any item has success === false, so callers get standard error semantics.
214+
*
211215
* @param {Array<Object>} subscriptionsData - Array of subscription data objects
212216
* @returns {Promise} Promise resolving to the mutation result
217+
* @throws {Error} If any item in the bulk response has success === false.
218+
* The error includes `response` (full API response) and `failures` (failed items).
213219
*/
214220
const updateSubscriptions = async (subscriptionsData) => {
215-
return updateSubscriptionsMutation.mutateAsync({
221+
const response = await updateSubscriptionsMutation.mutateAsync({
216222
parameters: {},
217223
body: {
218224
subscriptions: subscriptionsData
219225
}
220226
})
227+
228+
const results = response?.results || []
229+
const failures = results.filter((r) => r.success === false)
230+
231+
if (failures.length > 0) {
232+
failures.forEach((failure) => {
233+
console.error(
234+
'[useMarketingConsent] Bulk update item failed:',
235+
failure.error || failure
236+
)
237+
})
238+
239+
const error = new Error(
240+
`${failures.length} of ${results.length} subscription update(s) failed.`
241+
)
242+
error.response = response
243+
error.failures = failures
244+
throw error
245+
}
246+
247+
return response
221248
}
222249

223250
return {

packages/template-retail-react-app/app/hooks/use-marketing-consent.test.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,194 @@ describe('useMarketingConsent', () => {
667667
expect(response).toEqual(mockResponse)
668668
})
669669

670+
test('throws when bulk response contains failed items (207 Multi-Status)', async () => {
671+
const mock207Response = {
672+
results: [
673+
{
674+
channel: 'email',
675+
contactPointValue: 'customer@example.com',
676+
error: {
677+
code: 'UPDATE_FAILED',
678+
message: 'Failed to update consent subscription'
679+
},
680+
status: 'opt_in',
681+
subscriptionId: 'marketing-email',
682+
success: false
683+
}
684+
]
685+
}
686+
const mockMutateAsync = jest.fn().mockResolvedValue(mock207Response)
687+
useShopperConsentsMutation.mockImplementation((mutationType) => {
688+
if (mutationType === ShopperConsentsMutations.UpdateSubscriptions) {
689+
return {
690+
...mockUseMutationResult,
691+
mutateAsync: mockMutateAsync
692+
}
693+
}
694+
return mockUseMutationResult
695+
})
696+
697+
const {result} = renderHook(() => useMarketingConsent())
698+
699+
await expect(
700+
result.current.updateSubscriptions([
701+
{
702+
subscriptionId: 'marketing-email',
703+
channel: 'email',
704+
status: 'opt_in',
705+
contactPointValue: 'customer@example.com'
706+
}
707+
])
708+
).rejects.toThrow('1 of 1 subscription update(s) failed.')
709+
})
710+
711+
test('attaches response and failures to error thrown on 207', async () => {
712+
const mock207Response = {
713+
results: [
714+
{
715+
subscriptionId: 'marketing-email',
716+
success: false,
717+
error: {code: 'UPDATE_FAILED', message: 'Failed'}
718+
},
719+
{
720+
subscriptionId: 'marketing-sms',
721+
success: true
722+
}
723+
]
724+
}
725+
const mockMutateAsync = jest.fn().mockResolvedValue(mock207Response)
726+
useShopperConsentsMutation.mockImplementation((mutationType) => {
727+
if (mutationType === ShopperConsentsMutations.UpdateSubscriptions) {
728+
return {
729+
...mockUseMutationResult,
730+
mutateAsync: mockMutateAsync
731+
}
732+
}
733+
return mockUseMutationResult
734+
})
735+
736+
const {result} = renderHook(() => useMarketingConsent())
737+
738+
const promise = result.current.updateSubscriptions([
739+
{
740+
subscriptionId: 'marketing-email',
741+
channel: 'email',
742+
status: 'opt_in',
743+
contactPointValue: 'customer@example.com'
744+
}
745+
])
746+
747+
await expect(promise).rejects.toThrow('1 of 2 subscription update(s) failed.')
748+
749+
const err = await promise.catch((e) => e)
750+
expect(err.response).toEqual(mock207Response)
751+
expect(err.failures).toHaveLength(1)
752+
expect(err.failures[0].subscriptionId).toBe('marketing-email')
753+
})
754+
755+
test('resolves successfully when all results have success: true', async () => {
756+
const allSuccessResponse = {
757+
results: [
758+
{subscriptionId: 'marketing-email', success: true},
759+
{subscriptionId: 'marketing-sms', success: true}
760+
]
761+
}
762+
const mockMutateAsync = jest.fn().mockResolvedValue(allSuccessResponse)
763+
useShopperConsentsMutation.mockImplementation((mutationType) => {
764+
if (mutationType === ShopperConsentsMutations.UpdateSubscriptions) {
765+
return {
766+
...mockUseMutationResult,
767+
mutateAsync: mockMutateAsync
768+
}
769+
}
770+
return mockUseMutationResult
771+
})
772+
773+
const {result} = renderHook(() => useMarketingConsent())
774+
775+
const response = await result.current.updateSubscriptions([
776+
{
777+
subscriptionId: 'marketing-email',
778+
channel: 'email',
779+
status: 'opt_in',
780+
contactPointValue: 'customer@example.com'
781+
}
782+
])
783+
784+
expect(response).toEqual(allSuccessResponse)
785+
})
786+
787+
test('resolves successfully when response has no results array', async () => {
788+
const noResultsResponse = {success: true}
789+
const mockMutateAsync = jest.fn().mockResolvedValue(noResultsResponse)
790+
useShopperConsentsMutation.mockImplementation((mutationType) => {
791+
if (mutationType === ShopperConsentsMutations.UpdateSubscriptions) {
792+
return {
793+
...mockUseMutationResult,
794+
mutateAsync: mockMutateAsync
795+
}
796+
}
797+
return mockUseMutationResult
798+
})
799+
800+
const {result} = renderHook(() => useMarketingConsent())
801+
802+
const response = await result.current.updateSubscriptions([
803+
{
804+
subscriptionId: 'marketing-email',
805+
channel: 'email',
806+
status: 'opt_in',
807+
contactPointValue: 'customer@example.com'
808+
}
809+
])
810+
811+
expect(response).toEqual(noResultsResponse)
812+
})
813+
814+
test('logs each failed item error to console on 207', async () => {
815+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
816+
const mock207Response = {
817+
results: [
818+
{
819+
subscriptionId: 'marketing-email',
820+
success: false,
821+
error: {code: 'UPDATE_FAILED', message: 'Failed'}
822+
}
823+
]
824+
}
825+
const mockMutateAsync = jest.fn().mockResolvedValue(mock207Response)
826+
useShopperConsentsMutation.mockImplementation((mutationType) => {
827+
if (mutationType === ShopperConsentsMutations.UpdateSubscriptions) {
828+
return {
829+
...mockUseMutationResult,
830+
mutateAsync: mockMutateAsync
831+
}
832+
}
833+
return mockUseMutationResult
834+
})
835+
836+
const {result} = renderHook(() => useMarketingConsent())
837+
838+
try {
839+
await result.current.updateSubscriptions([
840+
{
841+
subscriptionId: 'marketing-email',
842+
channel: 'email',
843+
status: 'opt_in',
844+
contactPointValue: 'customer@example.com'
845+
}
846+
])
847+
} catch {
848+
// expected
849+
}
850+
851+
expect(consoleSpy).toHaveBeenCalledWith(
852+
'[useMarketingConsent] Bulk update item failed:',
853+
{code: 'UPDATE_FAILED', message: 'Failed'}
854+
)
855+
consoleSpy.mockRestore()
856+
})
857+
670858
test('reflects loading state during mutation', () => {
671859
useShopperConsentsMutation.mockImplementation((mutationType) => {
672860
if (mutationType === ShopperConsentsMutations.UpdateSubscriptions) {

0 commit comments

Comments
 (0)