@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.
- package/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +304 -252
- package/@generated/graphql.ts +1 -0
- package/@generated/schema.graphql +1 -1
- package/CHANGELOG.md +82 -0
- package/package.json +6 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
- package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
- package/src/components/account/Layout/Layout.tsx +5 -7
- package/src/components/account/Menu/Menu.tsx +1 -1
- package/src/components/navigation/Navbar/Navbar.tsx +3 -3
- package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
- package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
- package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
- package/src/experimental/myAccountServerSideProps.ts +4 -4
- package/src/pages/[slug]/p.tsx +18 -4
- package/src/pages/api/fs/logout.ts +18 -10
- package/src/pages/api/fs/switch-contract.ts +170 -21
- package/src/pages/api/graphql.ts +101 -60
- package/src/pages/pvt/account/403.tsx +5 -4
- package/src/pages/pvt/account/cards/index.tsx +5 -5
- package/src/pages/pvt/account/orders/[id].tsx +5 -5
- package/src/pages/pvt/account/orders/index.tsx +5 -5
- package/src/pages/pvt/account/profile.tsx +5 -5
- package/src/pages/pvt/account/quotes/index.tsx +5 -5
- package/src/pages/pvt/account/security.tsx +5 -5
- package/src/pages/pvt/account/user-details.tsx +6 -6
- package/src/sdk/account/getB2BSessionClaims.ts +1 -1
- package/src/sdk/account/getIsB2BBuyer.ts +28 -0
- package/src/sdk/account/refreshToken.ts +89 -11
- package/src/sdk/account/refreshTokenPaths.ts +25 -0
- package/src/sdk/account/useRefreshToken.ts +15 -11
- package/src/sdk/product/isProductNotFound.ts +13 -0
- package/src/sdk/product/usePageProductsQuery.ts +80 -21
- package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
- package/src/sdk/product/useProductQuery.ts +29 -0
- package/src/sdk/session/index.ts +46 -34
- package/src/server/cms/index.ts +59 -7
- package/src/server/cms/pdp.ts +10 -5
- package/src/server/cms/plp.ts +10 -5
- package/src/server/content/promiseCache.ts +68 -0
- package/src/server/content/service.ts +71 -51
- package/src/utils/getCheckoutParentDomain.ts +53 -0
- package/src/utils/isLocalHost.ts +2 -16
- package/src/utils/shouldBypassRefreshToken.ts +63 -0
- package/src/utils/trustedForwardedHost.ts +18 -0
- package/test/components/account/Layout.test.tsx +1 -1
- package/test/components/account/Menu.browser.test.tsx +7 -5
- package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
- package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
- package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
- package/test/components/navigation/Navbar.browser.test.tsx +3 -3
- package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
- package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
- package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
- package/test/pages/api/graphql.test.ts +175 -0
- package/test/pages/api/logout.test.ts +106 -0
- package/test/pages/api/switch-contract.test.ts +443 -15
- package/test/pages/pvt/account/403.test.ts +67 -0
- package/test/pages/pvt/account/cards/index.test.ts +3 -3
- package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
- package/test/sdk/account/refreshToken.browser.test.ts +10 -1
- package/test/sdk/account/refreshToken.test.ts +150 -18
- package/test/sdk/account/useRefreshToken.browser.test.ts +72 -0
- package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
- package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
- package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
- package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
- package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
- package/test/sdk/session/validateSession.browser.test.ts +111 -5
- package/test/server/cms/index.test.ts +102 -9
- package/test/server/content/promiseCache.test.ts +150 -0
- package/test/server/content/service.test.ts +277 -11
- package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
- package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
- package/test/utils/trustedForwardedHost.test.ts +21 -0
- package/src/sdk/account/getIsRepresentative.ts +0 -20
- 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
|
|
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/
|
|
34
|
-
|
|
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 () => ({
|
|
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
|
-
|
|
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 () =>
|
|
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(
|
|
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('
|
|
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 () =>
|
|
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(
|
|
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('
|
|
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
|
+
})
|