@faststore/core 4.10.1 → 4.10.2-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +304 -252
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +82 -0
  6. package/package.json +6 -6
  7. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
  8. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
  9. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
  10. package/src/components/account/Layout/Layout.tsx +5 -7
  11. package/src/components/account/Menu/Menu.tsx +1 -1
  12. package/src/components/navigation/Navbar/Navbar.tsx +3 -3
  13. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
  14. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
  15. package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
  16. package/src/experimental/myAccountServerSideProps.ts +4 -4
  17. package/src/pages/[slug]/p.tsx +18 -4
  18. package/src/pages/api/fs/logout.ts +18 -10
  19. package/src/pages/api/fs/switch-contract.ts +170 -21
  20. package/src/pages/api/graphql.ts +101 -60
  21. package/src/pages/pvt/account/403.tsx +5 -4
  22. package/src/pages/pvt/account/cards/index.tsx +5 -5
  23. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  24. package/src/pages/pvt/account/orders/index.tsx +5 -5
  25. package/src/pages/pvt/account/profile.tsx +5 -5
  26. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  27. package/src/pages/pvt/account/security.tsx +5 -5
  28. package/src/pages/pvt/account/user-details.tsx +6 -6
  29. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  30. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  31. package/src/sdk/account/refreshToken.ts +89 -11
  32. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  33. package/src/sdk/account/useRefreshToken.ts +15 -11
  34. package/src/sdk/product/isProductNotFound.ts +13 -0
  35. package/src/sdk/product/usePageProductsQuery.ts +80 -21
  36. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  37. package/src/sdk/product/useProductQuery.ts +29 -0
  38. package/src/sdk/session/index.ts +46 -34
  39. package/src/server/cms/index.ts +59 -7
  40. package/src/server/cms/pdp.ts +10 -5
  41. package/src/server/cms/plp.ts +10 -5
  42. package/src/server/content/promiseCache.ts +68 -0
  43. package/src/server/content/service.ts +71 -51
  44. package/src/utils/getCheckoutParentDomain.ts +53 -0
  45. package/src/utils/isLocalHost.ts +2 -16
  46. package/src/utils/shouldBypassRefreshToken.ts +63 -0
  47. package/src/utils/trustedForwardedHost.ts +18 -0
  48. package/test/components/account/Layout.test.tsx +1 -1
  49. package/test/components/account/Menu.browser.test.tsx +7 -5
  50. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  51. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  52. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  53. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  54. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  55. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  56. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  57. package/test/pages/api/graphql.test.ts +175 -0
  58. package/test/pages/api/logout.test.ts +106 -0
  59. package/test/pages/api/switch-contract.test.ts +443 -15
  60. package/test/pages/pvt/account/403.test.ts +67 -0
  61. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  62. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  63. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  64. package/test/sdk/account/refreshToken.test.ts +150 -18
  65. package/test/sdk/account/useRefreshToken.browser.test.ts +72 -0
  66. package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
  67. package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
  68. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  69. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  70. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  71. package/test/sdk/session/validateSession.browser.test.ts +111 -5
  72. package/test/server/cms/index.test.ts +102 -9
  73. package/test/server/content/promiseCache.test.ts +150 -0
  74. package/test/server/content/service.test.ts +277 -11
  75. package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
  76. package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
  77. package/test/utils/trustedForwardedHost.test.ts +21 -0
  78. package/src/sdk/account/getIsRepresentative.ts +0 -20
  79. package/test/utils/isLocalHost.browser.test.ts +0 -37
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  const mockExecute = vi.hoisted(() => vi.fn())
4
4
  const mockGetB2BSessionClaims = vi.hoisted(() => vi.fn())
5
- const mockGetIsRepresentative = vi.hoisted(() => vi.fn(() => true))
5
+ const mockGetIsB2BBuyer = vi.hoisted(() => vi.fn(() => true))
6
6
  const mockGetMyAccountRedirect = vi.hoisted(() =>
7
7
  vi.fn(() => ({ isFaststoreMyAccountEnabled: true, redirect: undefined }))
8
8
  )
@@ -30,8 +30,8 @@ vi.mock('src/components/cms/GlobalSections', () => ({
30
30
  vi.mock('src/sdk/account/getB2BSessionClaims', () => ({
31
31
  getB2BSessionClaims: mockGetB2BSessionClaims,
32
32
  }))
33
- vi.mock('src/sdk/account/getIsRepresentative', () => ({
34
- getIsRepresentative: mockGetIsRepresentative,
33
+ vi.mock('src/sdk/account/getIsB2BBuyer', () => ({
34
+ getIsB2BBuyer: mockGetIsB2BBuyer,
35
35
  }))
36
36
  vi.mock('src/utils/myAccountRedirect', () => ({
37
37
  getMyAccountRedirect: mockGetMyAccountRedirect,
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getIsB2BBuyer } from '../../../src/sdk/account/getIsB2BBuyer'
3
+
4
+ const ACCOUNT = 'storeaccount'
5
+
6
+ const makeJwt = (payload: Record<string, unknown>) =>
7
+ `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`
8
+
9
+ const withToken = (payload: Record<string, unknown>) => ({
10
+ headers: { cookie: `VtexIdclientAutCookie_${ACCOUNT}=${makeJwt(payload)}` },
11
+ account: ACCOUNT,
12
+ })
13
+
14
+ describe('getIsB2BBuyer', () => {
15
+ it('is true when the token has a unit and a contract', () => {
16
+ expect(
17
+ getIsB2BBuyer(withToken({ unitId: 'unit-1', customerId: 'contract-1' }))
18
+ ).toBe(true)
19
+ })
20
+
21
+ it('does not depend on the isRepresentative claim', () => {
22
+ expect(
23
+ getIsB2BBuyer(
24
+ withToken({
25
+ unitId: 'unit-1',
26
+ customerId: 'contract-1',
27
+ isRepresentative: false,
28
+ })
29
+ )
30
+ ).toBe(true)
31
+ expect(getIsB2BBuyer(withToken({ isRepresentative: true }))).toBe(false)
32
+ })
33
+
34
+ it.each([
35
+ ['without a unit', { customerId: 'contract-1' }],
36
+ ['without a contract', { unitId: 'unit-1' }],
37
+ ['with blank claims', { unitId: ' ', customerId: '' }],
38
+ ['with non-string claims', { unitId: 1, customerId: true }],
39
+ ])('is false %s', (_label, payload) => {
40
+ expect(getIsB2BBuyer(withToken(payload))).toBe(false)
41
+ })
42
+
43
+ it('is false without an auth cookie or with a malformed one', () => {
44
+ expect(getIsB2BBuyer({ headers: {}, account: ACCOUNT })).toBe(false)
45
+ expect(
46
+ getIsB2BBuyer({
47
+ headers: { cookie: `VtexIdclientAutCookie_${ACCOUNT}=not-a-jwt` },
48
+ account: ACCOUNT,
49
+ })
50
+ ).toBe(false)
51
+ })
52
+ })
@@ -14,7 +14,7 @@ describe('refreshTokenRequest (browser)', () => {
14
14
  beforeEach(() => {
15
15
  mockFetch.mockResolvedValue({
16
16
  status: 200,
17
- json: async () => ({ status: 'success' }),
17
+ json: async () => ({ authStatus: 'Success' }),
18
18
  })
19
19
  })
20
20
 
@@ -31,4 +31,13 @@ describe('refreshTokenRequest (browser)', () => {
31
31
  expect(url).toBe('/api/vtexid/refreshtoken/webstore')
32
32
  expect(init.headers).toEqual({ 'content-type': 'application/json' })
33
33
  })
34
+
35
+ it('uses the relative Authenticator route for B2B sessions', async () => {
36
+ await refreshTokenRequest({ isB2B: true })
37
+
38
+ expect(mockFetch).toHaveBeenCalledTimes(1)
39
+ expect(mockFetch.mock.calls[0][0]).toBe(
40
+ '/api/authenticator/v1/refreshtoken/webstore'
41
+ )
42
+ })
34
43
  })
@@ -13,15 +13,29 @@ vi.mock('src/utils/utilities', () => ({
13
13
  }))
14
14
 
15
15
  import {
16
+ REFRESH_AFTER_FALLBACK_SECONDS,
17
+ getNextRefreshAfter,
18
+ getRefreshAfterSeconds,
19
+ isB2BSession,
16
20
  isRefreshTokenSuccessful,
17
21
  refreshTokenRequest,
18
22
  } from '../../../src/sdk/account/refreshToken'
19
23
 
20
- describe('refreshTokenRequest (node)', () => {
24
+ const AUTHENTICATOR_URL =
25
+ 'https://b2bfaststore.vtexfaststore.com/api/authenticator/v1/refreshtoken/webstore'
26
+ const LEGACY_URL =
27
+ 'https://b2bfaststore.vtexfaststore.com/api/vtexid/refreshtoken/webstore'
28
+
29
+ const authenticatorSuccess = {
30
+ authStatus: 'Success',
31
+ refreshAfter: 1792717383,
32
+ }
33
+
34
+ describe('refreshTokenRequest (node, B2B session)', () => {
21
35
  beforeEach(() => {
22
36
  mockFetch.mockResolvedValue({
23
37
  status: 200,
24
- json: async () => ({ status: 'success', refreshAfter: '123' }),
38
+ json: async () => authenticatorSuccess,
25
39
  })
26
40
  })
27
41
 
@@ -29,16 +43,14 @@ describe('refreshTokenRequest (node)', () => {
29
43
  vi.clearAllMocks()
30
44
  })
31
45
 
32
- it('posts to the store origin with Host header on the server', async () => {
33
- const result = await refreshTokenRequest()
46
+ it('posts to the Authenticator route on the store origin with Host header on the server', async () => {
47
+ const result = await refreshTokenRequest({ isB2B: true })
34
48
 
35
- expect(result).toEqual({ status: 'success', refreshAfter: '123' })
49
+ expect(result).toEqual(authenticatorSuccess)
36
50
  expect(mockFetch).toHaveBeenCalledTimes(1)
37
51
 
38
52
  const [url, init] = mockFetch.mock.calls[0]
39
- expect(url).toBe(
40
- 'https://b2bfaststore.vtexfaststore.com/api/vtexid/refreshtoken/webstore'
41
- )
53
+ expect(url).toBe(AUTHENTICATOR_URL)
42
54
  expect(init.method).toBe('POST')
43
55
  expect(init.credentials).toBe('include')
44
56
  expect(init.headers).toMatchObject({
@@ -48,13 +60,35 @@ describe('refreshTokenRequest (node)', () => {
48
60
  expect(JSON.parse(init.body as string)).toEqual({})
49
61
  })
50
62
 
51
- it('retries when the response is not 200 and returns undefined after failures', async () => {
52
- mockFetch
53
- .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
54
- .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
55
- .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
63
+ it('does not fall back to the legacy route when the Authenticator fails', async () => {
64
+ mockFetch.mockResolvedValue({ status: 401, json: async () => ({}) })
56
65
 
57
- const result = await refreshTokenRequest()
66
+ const result = await refreshTokenRequest({ isB2B: true })
67
+
68
+ expect(result).toBeUndefined()
69
+ expect(mockFetch).toHaveBeenCalledTimes(3)
70
+ expect(
71
+ mockFetch.mock.calls.every(([url]) => url === AUTHENTICATOR_URL)
72
+ ).toBe(true)
73
+ })
74
+
75
+ it('returns the Authenticator answer as is when it does not report success', async () => {
76
+ mockFetch.mockResolvedValue({
77
+ status: 200,
78
+ json: async () => ({ authStatus: 'WrongCredentials' }),
79
+ })
80
+
81
+ const result = await refreshTokenRequest({ isB2B: true })
82
+
83
+ expect(result).toEqual({ authStatus: 'WrongCredentials' })
84
+ expect(isRefreshTokenSuccessful(result)).toBe(false)
85
+ expect(mockFetch).toHaveBeenCalledTimes(1)
86
+ })
87
+
88
+ it('retries non-200 answers on the Authenticator route only', async () => {
89
+ mockFetch.mockResolvedValue({ status: 500, json: async () => ({}) })
90
+
91
+ const result = await refreshTokenRequest({ isB2B: true })
58
92
 
59
93
  expect(result).toBeUndefined()
60
94
  expect(mockFetch).toHaveBeenCalledTimes(3)
@@ -65,23 +99,121 @@ describe('refreshTokenRequest (node)', () => {
65
99
  .mockRejectedValueOnce(new Error('network'))
66
100
  .mockResolvedValueOnce({
67
101
  status: 200,
68
- json: async () => ({ status: 'success' }),
102
+ json: async () => authenticatorSuccess,
69
103
  })
70
104
 
71
- const result = await refreshTokenRequest()
105
+ const result = await refreshTokenRequest({ isB2B: true })
72
106
 
73
- expect(result).toEqual({ status: 'success' })
107
+ expect(result).toEqual(authenticatorSuccess)
74
108
  expect(mockFetch).toHaveBeenCalledTimes(2)
109
+ expect(mockFetch.mock.calls[1][0]).toBe(AUTHENTICATOR_URL)
110
+ })
111
+ })
112
+
113
+ describe('refreshTokenRequest (node, non-B2B session)', () => {
114
+ beforeEach(() => {
115
+ mockFetch.mockResolvedValue({
116
+ status: 200,
117
+ json: async () => ({ status: 'Success', refreshAfter: '123' }),
118
+ })
119
+ })
120
+
121
+ afterEach(() => {
122
+ vi.clearAllMocks()
123
+ })
124
+
125
+ it('only calls the legacy route, as VTEX ID recommends outside B2B', async () => {
126
+ const result = await refreshTokenRequest()
127
+
128
+ expect(result).toEqual({ status: 'Success', refreshAfter: '123' })
129
+ expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([LEGACY_URL])
130
+ })
131
+
132
+ it('retries 5xx on the legacy route only', async () => {
133
+ mockFetch.mockResolvedValue({ status: 500, json: async () => ({}) })
134
+
135
+ const result = await refreshTokenRequest({ isB2B: false })
136
+
137
+ expect(result).toBeUndefined()
138
+ expect(mockFetch).toHaveBeenCalledTimes(3)
139
+ expect(mockFetch.mock.calls.every(([url]) => url === LEGACY_URL)).toBe(true)
140
+ })
141
+ })
142
+
143
+ describe('isB2BSession', () => {
144
+ it('is true when the session has both a unit and a contract', () => {
145
+ expect(
146
+ isB2BSession({ b2b: { unitId: 'unit-1', customerId: 'contract-1' } })
147
+ ).toBe(true)
148
+ })
149
+
150
+ it.each([
151
+ ['no b2b', { b2b: null }],
152
+ ['an empty unitId', { b2b: { unitId: '', customerId: 'contract-1' } }],
153
+ ['an empty customerId', { b2b: { unitId: 'unit-1', customerId: '' } }],
154
+ ['no ids', { b2b: {} }],
155
+ ])('is false with %s', (_, session) => {
156
+ // `b2b` exists for every isRepresentative token, which can be true
157
+ // outside B2B, so the ids decide.
158
+ expect(isB2BSession(session)).toBe(false)
159
+ })
160
+
161
+ it('is false without a session', () => {
162
+ expect(isB2BSession(null)).toBe(false)
163
+ expect(isB2BSession(undefined)).toBe(false)
75
164
  })
76
165
  })
77
166
 
78
167
  describe('isRefreshTokenSuccessful', () => {
79
- it('returns true when status is success (case-insensitive)', () => {
168
+ it('accepts the legacy status field (case-insensitive)', () => {
80
169
  expect(isRefreshTokenSuccessful({ status: 'Success' })).toBe(true)
81
170
  })
82
171
 
172
+ it('accepts the Authenticator authStatus field', () => {
173
+ expect(isRefreshTokenSuccessful({ authStatus: 'Success' })).toBe(true)
174
+ })
175
+
83
176
  it('returns false when status is missing or not success', () => {
84
177
  expect(isRefreshTokenSuccessful(undefined)).toBe(false)
85
178
  expect(isRefreshTokenSuccessful({ status: 'failed' })).toBe(false)
179
+ expect(isRefreshTokenSuccessful({ authStatus: 'failed' })).toBe(false)
180
+ })
181
+ })
182
+
183
+ describe('getRefreshAfterSeconds', () => {
184
+ it('keeps epoch seconds from the Authenticator', () => {
185
+ expect(getRefreshAfterSeconds({ refreshAfter: 1792717383 })).toBe(
186
+ '1792717383'
187
+ )
188
+ expect(getRefreshAfterSeconds({ refreshAfter: '1792717383' })).toBe(
189
+ '1792717383'
190
+ )
191
+ })
192
+
193
+ it('converts the legacy ISO date to epoch seconds', () => {
194
+ expect(
195
+ getRefreshAfterSeconds({ refreshAfter: '2026-10-20T05:36:52+00:00' })
196
+ ).toBe(String(Date.UTC(2026, 9, 20, 5, 36, 52) / 1000))
197
+ })
198
+
199
+ it('returns null when the value is missing or unparseable', () => {
200
+ expect(getRefreshAfterSeconds(undefined)).toBeNull()
201
+ expect(getRefreshAfterSeconds({})).toBeNull()
202
+ expect(getRefreshAfterSeconds({ refreshAfter: '' })).toBeNull()
203
+ expect(getRefreshAfterSeconds({ refreshAfter: 'soon' })).toBeNull()
204
+ })
205
+ })
206
+
207
+ describe('getNextRefreshAfter', () => {
208
+ it('uses the refreshAfter from the response', () => {
209
+ expect(getNextRefreshAfter({ refreshAfter: 1792717383 }, 100)).toBe(
210
+ '1792717383'
211
+ )
212
+ })
213
+
214
+ it('falls back to a short delay from now when the response has none', () => {
215
+ expect(getNextRefreshAfter({ authStatus: 'Success' }, 100)).toBe(
216
+ String(100 + REFRESH_AFTER_FALLBACK_SECONDS)
217
+ )
86
218
  })
87
219
  })
@@ -0,0 +1,72 @@
1
+ import { renderHook, waitFor } from '@testing-library/react'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const mockRefreshTokenRequest = vi.hoisted(() => vi.fn())
5
+ const mockLogoutAndClearSession = vi.hoisted(() => vi.fn())
6
+
7
+ vi.mock('../../../src/sdk/account/refreshToken', async (importOriginal) => ({
8
+ ...(await importOriginal<
9
+ typeof import('../../../src/sdk/account/refreshToken')
10
+ >()),
11
+ refreshTokenRequest: mockRefreshTokenRequest,
12
+ isRefreshTokenSuccessful: () => false,
13
+ }))
14
+
15
+ vi.mock('../../../src/sdk/session', () => ({
16
+ logoutAndClearSession: mockLogoutAndClearSession,
17
+ sessionStore: { read: () => null, readInitial: () => ({}) },
18
+ }))
19
+
20
+ import { useRefreshToken } from '../../../src/sdk/account/useRefreshToken'
21
+
22
+ const originalLocation = window.location
23
+
24
+ function stubHostname(hostname: string) {
25
+ // jsdom forbids assigning to `window.location` directly, so we redefine the
26
+ // descriptor with a minimal stub that exposes the hostname we want to test.
27
+ Object.defineProperty(window, 'location', {
28
+ configurable: true,
29
+ value: { ...originalLocation, hostname },
30
+ })
31
+ }
32
+
33
+ describe('useRefreshToken', () => {
34
+ beforeEach(() => {
35
+ mockRefreshTokenRequest.mockResolvedValue({ status: 'error' })
36
+ mockLogoutAndClearSession.mockResolvedValue(undefined)
37
+ })
38
+
39
+ afterEach(() => {
40
+ vi.clearAllMocks()
41
+ Object.defineProperty(window, 'location', {
42
+ configurable: true,
43
+ value: originalLocation,
44
+ })
45
+ })
46
+
47
+ it.each(['localhost', 'brandless-abc123.preview.vtex.app'])(
48
+ 'shows the 403 view without refreshing or logging out on %s',
49
+ async (hostname) => {
50
+ stubHostname(hostname)
51
+
52
+ const { result } = renderHook(() => useRefreshToken(true))
53
+
54
+ await waitFor(() => expect(result.current.shouldShow403).toBe(true))
55
+ expect(mockRefreshTokenRequest).not.toHaveBeenCalled()
56
+ expect(mockLogoutAndClearSession).not.toHaveBeenCalled()
57
+ }
58
+ )
59
+
60
+ it.each(['brandless.vtex.app', 'brandless.fast.store'])(
61
+ 'refreshes the token and logs out when it fails on %s',
62
+ async (hostname) => {
63
+ stubHostname(hostname)
64
+
65
+ const { result } = renderHook(() => useRefreshToken(true))
66
+
67
+ await waitFor(() => expect(result.current.shouldShow403).toBe(true))
68
+ expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1)
69
+ expect(mockLogoutAndClearSession).toHaveBeenCalledTimes(1)
70
+ }
71
+ )
72
+ })
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { renderHook } from '@testing-library/react'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const mockUseSession = vi.hoisted(() => vi.fn())
9
+ const mockUseQuery = vi.hoisted(() => vi.fn())
10
+ const mockUseLocalizedVariables = vi.hoisted(() => vi.fn())
11
+
12
+ vi.mock('@generated', () => ({ gql: (query: unknown) => query }))
13
+ vi.mock('discovery.config', () => ({
14
+ default: { deliveryPromise: { enabled: false } },
15
+ }))
16
+ vi.mock('@faststore/sdk', () => ({
17
+ useSearch: () => ({
18
+ state: { sort: 'score_desc', term: '', selectedFacets: [] },
19
+ itemsPerPage: 12,
20
+ }),
21
+ }))
22
+ vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
23
+ vi.mock('src/sdk/graphql/useQuery', () => ({ useQuery: mockUseQuery }))
24
+ vi.mock('src/sdk/product/useLocalizedVariables', () => ({
25
+ useLocalizedVariables: mockUseLocalizedVariables,
26
+ }))
27
+
28
+ import {
29
+ hashContractScope,
30
+ useCreateUseGalleryPage,
31
+ } from '../../../src/sdk/product/usePageProductsQuery'
32
+
33
+ const serverManyProductsVariables = {
34
+ first: 12,
35
+ after: '0',
36
+ sort: 'score_desc' as const,
37
+ term: '',
38
+ selectedFacets: [
39
+ { key: 'category-1', value: 'computers---tablets' },
40
+ { key: 'channel', value: '{"salesChannel":"1"}' },
41
+ { key: 'locale', value: 'en-CA' },
42
+ ],
43
+ sponsoredCount: 3,
44
+ }
45
+
46
+ type GalleryProps = NonNullable<Parameters<typeof useCreateUseGalleryPage>[0]>
47
+
48
+ const initialPages = {
49
+ search: { products: { pageInfo: { totalCount: 2 }, edges: [] } },
50
+ } as unknown as GalleryProps['initialPages']
51
+
52
+ const renderFirstGalleryPage = () => {
53
+ const { result: created } = renderHook(() =>
54
+ useCreateUseGalleryPage({ initialPages, serverManyProductsVariables })
55
+ )
56
+
57
+ return renderHook(() => created.current.useGalleryPage(0))
58
+ }
59
+
60
+ describe('useCreateUseGalleryPage', () => {
61
+ afterEach(() => {
62
+ vi.clearAllMocks()
63
+ })
64
+
65
+ it('reuses the SSG first page for anonymous shoppers', () => {
66
+ mockUseSession.mockReturnValue({
67
+ postalCode: null,
68
+ b2b: null,
69
+ isValidating: false,
70
+ })
71
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
72
+ mockUseQuery.mockReturnValue({ data: null })
73
+
74
+ const { result } = renderFirstGalleryPage()
75
+
76
+ expect(mockUseQuery).toHaveBeenCalledWith(
77
+ expect.anything(),
78
+ serverManyProductsVariables,
79
+ expect.objectContaining({ doNotRun: true })
80
+ )
81
+ expect(result.current.data).toBe(initialPages)
82
+ })
83
+
84
+ it('re-fetches the first page for B2B buyers so the contract assortment applies', () => {
85
+ mockUseSession.mockReturnValue({
86
+ postalCode: null,
87
+ b2b: { customerId: 'contract-137' },
88
+ isValidating: false,
89
+ })
90
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
91
+ mockUseQuery.mockReturnValue({ data: null })
92
+
93
+ renderFirstGalleryPage()
94
+
95
+ expect(mockUseQuery).toHaveBeenCalledWith(
96
+ expect.anything(),
97
+ {
98
+ ...serverManyProductsVariables,
99
+ _contract: hashContractScope('contract-137'),
100
+ },
101
+ expect.objectContaining({ doNotRun: false })
102
+ )
103
+ })
104
+
105
+ it('still re-fetches for B2B buyers whose customerId resolves to an empty string', () => {
106
+ // `buildB2bSession` in @faststore/api can resolve `customerId` to `''`
107
+ // (see validateSessionHelpers.ts) even for a representative with an
108
+ // active contract. The fix must not depend on the value being truthy —
109
+ // only on the `b2b` session existing — or it silently falls back to the
110
+ // anonymous behavior (reusing the unfiltered SSG page).
111
+ mockUseSession.mockReturnValue({
112
+ postalCode: null,
113
+ b2b: { customerId: '' },
114
+ isValidating: false,
115
+ })
116
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
117
+ mockUseQuery.mockReturnValue({ data: null })
118
+
119
+ renderFirstGalleryPage()
120
+
121
+ expect(mockUseQuery).toHaveBeenCalledWith(
122
+ expect.anything(),
123
+ { ...serverManyProductsVariables, _contract: hashContractScope('b2b') },
124
+ expect.objectContaining({ doNotRun: false })
125
+ )
126
+ })
127
+
128
+ it('hashes the contract scope to a stable value', () => {
129
+ // Golden value: catches a change to the hash itself, which the other
130
+ // tests miss because they derive their expectations from it.
131
+ expect(hashContractScope('contract-137')).toBe('185k3l4sd4t')
132
+ })
133
+
134
+ it('keeps the raw contract id out of the query variables', () => {
135
+ mockUseSession.mockReturnValue({
136
+ postalCode: null,
137
+ b2b: { customerId: 'contract-137', unitId: 'unit-42' },
138
+ isValidating: false,
139
+ })
140
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
141
+ mockUseQuery.mockReturnValue({ data: null })
142
+
143
+ renderFirstGalleryPage()
144
+
145
+ // The variables go in the GET URL, so they must not carry the raw ids.
146
+ const variables = JSON.stringify(mockUseQuery.mock.calls.at(-1)?.[1])
147
+ expect(variables).not.toContain('contract-137')
148
+ expect(variables).not.toContain('unit-42')
149
+ expect(hashContractScope('contract-a')).not.toBe(
150
+ hashContractScope('contract-b')
151
+ )
152
+ })
153
+
154
+ it('tells B2B buyers apart by organizational unit when customerId is empty', () => {
155
+ mockUseSession.mockReturnValue({
156
+ postalCode: null,
157
+ b2b: { customerId: '', unitId: 'unit-42' },
158
+ isValidating: false,
159
+ })
160
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
161
+ mockUseQuery.mockReturnValue({ data: null })
162
+
163
+ renderFirstGalleryPage()
164
+
165
+ expect(mockUseQuery).toHaveBeenCalledWith(
166
+ expect.anything(),
167
+ {
168
+ ...serverManyProductsVariables,
169
+ _contract: hashContractScope('unit-42'),
170
+ },
171
+ expect.objectContaining({ doNotRun: false })
172
+ )
173
+ })
174
+
175
+ it('re-fetches the first page when the buyer switches contract', () => {
176
+ const contractPage = {
177
+ search: { products: { pageInfo: { totalCount: 1 }, edges: [] } },
178
+ }
179
+
180
+ mockUseSession.mockReturnValue({
181
+ postalCode: null,
182
+ b2b: { customerId: 'contract-a' },
183
+ isValidating: false,
184
+ })
185
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
186
+ mockUseQuery.mockReturnValue({ data: contractPage })
187
+
188
+ const { result: created } = renderHook(() =>
189
+ useCreateUseGalleryPage({ initialPages, serverManyProductsVariables })
190
+ )
191
+ const { rerender } = renderHook(() => created.current.useGalleryPage(0))
192
+
193
+ mockUseSession.mockReturnValue({
194
+ postalCode: null,
195
+ b2b: { customerId: 'contract-b' },
196
+ isValidating: false,
197
+ })
198
+ rerender()
199
+
200
+ expect(mockUseQuery).toHaveBeenLastCalledWith(
201
+ expect.anything(),
202
+ {
203
+ ...serverManyProductsVariables,
204
+ _contract: hashContractScope('contract-b'),
205
+ },
206
+ expect.objectContaining({ doNotRun: false })
207
+ )
208
+ })
209
+
210
+ it('stops scoping the first page once the B2B session ends', () => {
211
+ const contractPage = {
212
+ search: { products: { pageInfo: { totalCount: 1 }, edges: [] } },
213
+ }
214
+
215
+ mockUseSession.mockReturnValue({
216
+ postalCode: null,
217
+ b2b: { customerId: 'contract-a' },
218
+ isValidating: false,
219
+ })
220
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
221
+ mockUseQuery.mockReturnValue({ data: contractPage })
222
+
223
+ const { result: created } = renderHook(() =>
224
+ useCreateUseGalleryPage({ initialPages, serverManyProductsVariables })
225
+ )
226
+ const { rerender } = renderHook(() => created.current.useGalleryPage(0))
227
+
228
+ mockUseSession.mockReturnValue({
229
+ postalCode: null,
230
+ b2b: null,
231
+ isValidating: false,
232
+ })
233
+ rerender()
234
+
235
+ expect(mockUseQuery).toHaveBeenLastCalledWith(
236
+ expect.anything(),
237
+ serverManyProductsVariables,
238
+ expect.objectContaining({ doNotRun: false })
239
+ )
240
+ })
241
+
242
+ it('ignores the postal code while deliveryPromise is disabled', () => {
243
+ mockUseSession.mockReturnValue({
244
+ postalCode: '01310-100',
245
+ b2b: null,
246
+ isValidating: false,
247
+ })
248
+ mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
249
+ mockUseQuery.mockReturnValue({ data: null })
250
+
251
+ const { result } = renderFirstGalleryPage()
252
+
253
+ expect(mockUseQuery).toHaveBeenCalledWith(
254
+ expect.anything(),
255
+ serverManyProductsVariables,
256
+ expect.objectContaining({ doNotRun: true })
257
+ )
258
+ expect(result.current.data).toBe(initialPages)
259
+ })
260
+ })