@faststore/core 4.10.1 → 4.10.2
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 +278 -235
- package/@generated/graphql.ts +1 -0
- package/@generated/schema.graphql +1 -1
- package/CHANGELOG.md +86 -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
|
@@ -0,0 +1,130 @@
|
|
|
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: true } },
|
|
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
|
+
// `isDeliveryPromiseEnabled` is read when the module loads, so this scenario
|
|
61
|
+
// needs its own file with the flag mocked on.
|
|
62
|
+
describe('useCreateUseGalleryPage with deliveryPromise enabled', () => {
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.clearAllMocks()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('scopes the page by postal code and contract together for B2B buyers', () => {
|
|
68
|
+
mockUseSession.mockReturnValue({
|
|
69
|
+
postalCode: '01310-100',
|
|
70
|
+
b2b: { customerId: 'contract-137' },
|
|
71
|
+
isValidating: false,
|
|
72
|
+
})
|
|
73
|
+
mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
|
|
74
|
+
mockUseQuery.mockReturnValue({ data: null })
|
|
75
|
+
|
|
76
|
+
renderFirstGalleryPage()
|
|
77
|
+
|
|
78
|
+
expect(mockUseQuery).toHaveBeenCalledWith(
|
|
79
|
+
expect.anything(),
|
|
80
|
+
{
|
|
81
|
+
...serverManyProductsVariables,
|
|
82
|
+
_postalCode: '01310-100',
|
|
83
|
+
_contract: hashContractScope('contract-137'),
|
|
84
|
+
},
|
|
85
|
+
expect.objectContaining({ doNotRun: false })
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('still reuses the SSG page for an anonymous shopper with a postal code', () => {
|
|
90
|
+
mockUseSession.mockReturnValue({
|
|
91
|
+
postalCode: '01310-100',
|
|
92
|
+
b2b: null,
|
|
93
|
+
isValidating: false,
|
|
94
|
+
})
|
|
95
|
+
mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
|
|
96
|
+
mockUseQuery.mockReturnValue({ data: null })
|
|
97
|
+
|
|
98
|
+
const { result } = renderFirstGalleryPage()
|
|
99
|
+
|
|
100
|
+
expect(mockUseQuery).toHaveBeenCalledWith(
|
|
101
|
+
expect.anything(),
|
|
102
|
+
{ ...serverManyProductsVariables, _postalCode: '01310-100' },
|
|
103
|
+
expect.objectContaining({ doNotRun: true })
|
|
104
|
+
)
|
|
105
|
+
expect(result.current.data).toBe(initialPages)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('re-fetches when the postal code changed after the SSG page was seeded', () => {
|
|
109
|
+
mockUseSession.mockReturnValueOnce({
|
|
110
|
+
postalCode: null,
|
|
111
|
+
b2b: null,
|
|
112
|
+
isValidating: false,
|
|
113
|
+
})
|
|
114
|
+
mockUseSession.mockReturnValue({
|
|
115
|
+
postalCode: '01310-100',
|
|
116
|
+
b2b: null,
|
|
117
|
+
isValidating: false,
|
|
118
|
+
})
|
|
119
|
+
mockUseLocalizedVariables.mockReturnValue(serverManyProductsVariables)
|
|
120
|
+
mockUseQuery.mockReturnValue({ data: null })
|
|
121
|
+
|
|
122
|
+
renderFirstGalleryPage()
|
|
123
|
+
|
|
124
|
+
expect(mockUseQuery).toHaveBeenCalledWith(
|
|
125
|
+
expect.anything(),
|
|
126
|
+
{ ...serverManyProductsVariables, _postalCode: '01310-100' },
|
|
127
|
+
expect.objectContaining({ doNotRun: false })
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
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 mockReplace = vi.hoisted(() => vi.fn())
|
|
9
|
+
|
|
10
|
+
vi.mock('next/router', () => ({
|
|
11
|
+
useRouter: () => ({ replace: mockReplace }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { useProductNotFoundRedirect } from '../../../src/sdk/product/useProductNotFoundRedirect'
|
|
15
|
+
|
|
16
|
+
describe('useProductNotFoundRedirect', () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.clearAllMocks()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('replaces the PDP with the 404 page when the BFF answers 404', () => {
|
|
22
|
+
renderHook(() => useProductNotFoundRedirect({ status: 404 }))
|
|
23
|
+
|
|
24
|
+
expect(mockReplace).toHaveBeenCalledWith('/404')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('redirects without waiting for the session', () => {
|
|
28
|
+
// A segment restriction rejects the product for any shopper, B2B or not,
|
|
29
|
+
// so the hook does not read the session at all.
|
|
30
|
+
const { result } = renderHook(() =>
|
|
31
|
+
useProductNotFoundRedirect({ status: 404 })
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(result.current).toBe(true)
|
|
35
|
+
expect(mockReplace).toHaveBeenCalledTimes(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('navigates once even when the router changes identity', () => {
|
|
39
|
+
// The mocked `useRouter` returns a new object on every render.
|
|
40
|
+
const { rerender } = renderHook(() =>
|
|
41
|
+
useProductNotFoundRedirect({ status: 404 })
|
|
42
|
+
)
|
|
43
|
+
rerender()
|
|
44
|
+
rerender()
|
|
45
|
+
|
|
46
|
+
expect(mockReplace).toHaveBeenCalledTimes(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('redirects again for a later not-found after one that cleared', () => {
|
|
50
|
+
const { rerender } = renderHook(
|
|
51
|
+
({ error }: { error: unknown }) => useProductNotFoundRedirect(error),
|
|
52
|
+
{ initialProps: { error: { status: 404 } as unknown } }
|
|
53
|
+
)
|
|
54
|
+
rerender({ error: undefined })
|
|
55
|
+
rerender({ error: { status: 404 } })
|
|
56
|
+
|
|
57
|
+
expect(mockReplace).toHaveBeenCalledTimes(2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('recognizes a NotFoundError carried in the GraphQL extensions', () => {
|
|
61
|
+
renderHook(() =>
|
|
62
|
+
useProductNotFoundRedirect({ extensions: { type: 'NotFoundError' } })
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(mockReplace).toHaveBeenCalledTimes(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does nothing while the refetch has no error', () => {
|
|
69
|
+
const { result } = renderHook(() => useProductNotFoundRedirect(undefined))
|
|
70
|
+
|
|
71
|
+
expect(mockReplace).not.toHaveBeenCalled()
|
|
72
|
+
expect(result.current).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does nothing for errors other than not found', () => {
|
|
76
|
+
const { result } = renderHook(() =>
|
|
77
|
+
useProductNotFoundRedirect({ status: 500 })
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(mockReplace).not.toHaveBeenCalled()
|
|
81
|
+
expect(result.current).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderHook, waitFor } from '@testing-library/react'
|
|
6
|
+
import { createElement, type ReactNode } from 'react'
|
|
7
|
+
import { SWRConfig } from 'swr'
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
const mockRequest = vi.hoisted(() => vi.fn())
|
|
11
|
+
const mockReplace = vi.hoisted(() => vi.fn())
|
|
12
|
+
const mockUseSession = vi.hoisted(() => vi.fn())
|
|
13
|
+
const mockSharedRetry = vi.hoisted(() => vi.fn())
|
|
14
|
+
|
|
15
|
+
vi.mock('@generated', () => ({
|
|
16
|
+
gql: () => ({
|
|
17
|
+
__meta__: { operationName: 'ClientProductQuery', operationHash: 'hash' },
|
|
18
|
+
}),
|
|
19
|
+
}))
|
|
20
|
+
vi.mock('src/sdk/graphql/request', () => ({ request: mockRequest }))
|
|
21
|
+
vi.mock('src/utils/cookieCacheBusting', () => ({
|
|
22
|
+
getClientCacheBustingValue: () => null,
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
|
|
25
|
+
// Spies on the shared retry policy while keeping its behavior. SWR calls
|
|
26
|
+
// `onErrorRetry` synchronously when the error lands, so whether a retry was
|
|
27
|
+
// scheduled is known without waiting on the clock.
|
|
28
|
+
vi.mock('src/sdk/graphql/retryPolicy', async (importOriginal) => {
|
|
29
|
+
const actual =
|
|
30
|
+
await importOriginal<typeof import('src/sdk/graphql/retryPolicy')>()
|
|
31
|
+
mockSharedRetry.mockImplementation(actual.onErrorRetry)
|
|
32
|
+
|
|
33
|
+
return { ...actual, onErrorRetry: mockSharedRetry }
|
|
34
|
+
})
|
|
35
|
+
vi.mock('next/router', () => ({
|
|
36
|
+
useRouter: () => ({ replace: mockReplace }),
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
import { useProductNotFoundRedirect } from '../../../src/sdk/product/useProductNotFoundRedirect'
|
|
40
|
+
import { useProductQuery } from '../../../src/sdk/product/useProductQuery'
|
|
41
|
+
|
|
42
|
+
const fallbackData = { product: { id: '123' } } as any
|
|
43
|
+
|
|
44
|
+
// The real PDP wiring: the refetch error feeds the redirect hook.
|
|
45
|
+
const usePdpClientQuery = () => {
|
|
46
|
+
const productQuery = useProductQuery('123', fallbackData)
|
|
47
|
+
useProductNotFoundRedirect(productQuery.error)
|
|
48
|
+
|
|
49
|
+
return productQuery
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// SWR's cache is global; isolate it so a test never reads another one's result.
|
|
53
|
+
const isolatedCache = ({ children }: { children: ReactNode }) =>
|
|
54
|
+
createElement(SWRConfig, { value: { provider: () => new Map() } }, children)
|
|
55
|
+
|
|
56
|
+
describe('useProductQuery', () => {
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.clearAllMocks()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('exposes a failed refetch as `error` and stops validating', async () => {
|
|
62
|
+
mockUseSession.mockReturnValue({
|
|
63
|
+
channel: '{"salesChannel":"1"}',
|
|
64
|
+
locale: 'en-US',
|
|
65
|
+
b2b: null,
|
|
66
|
+
})
|
|
67
|
+
mockRequest.mockRejectedValue({ status: 404, message: 'Not Found' })
|
|
68
|
+
|
|
69
|
+
const { result } = renderHook(usePdpClientQuery, { wrapper: isolatedCache })
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(result.current.error).toEqual(expect.anything()))
|
|
72
|
+
|
|
73
|
+
expect(result.current.error).toMatchObject({ status: 404 })
|
|
74
|
+
expect(result.current.isValidating).toBe(false)
|
|
75
|
+
// The data stays the static product, and the failed call is not retried.
|
|
76
|
+
expect(result.current.data).toBe(fallbackData)
|
|
77
|
+
expect(mockRequest).toHaveBeenCalledTimes(1)
|
|
78
|
+
// Shoppers without a B2B session are sent to the 404 page too.
|
|
79
|
+
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/404'))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does not retry a NotFoundError carried in the GraphQL extensions', async () => {
|
|
83
|
+
mockUseSession.mockReturnValue({
|
|
84
|
+
channel: '{"salesChannel":"1"}',
|
|
85
|
+
locale: 'en-US',
|
|
86
|
+
b2b: { customerId: 'contract-137' },
|
|
87
|
+
})
|
|
88
|
+
mockRequest.mockRejectedValue({ extensions: { type: 'NotFoundError' } })
|
|
89
|
+
|
|
90
|
+
renderHook(usePdpClientQuery, { wrapper: isolatedCache })
|
|
91
|
+
|
|
92
|
+
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/404'))
|
|
93
|
+
// The shared policy is what schedules a retry; it is never reached.
|
|
94
|
+
expect(mockSharedRetry).not.toHaveBeenCalled()
|
|
95
|
+
expect(mockRequest).toHaveBeenCalledTimes(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('sends a B2B shopper to the 404 page when the refetch answers 404', async () => {
|
|
99
|
+
mockUseSession.mockReturnValue({
|
|
100
|
+
channel: '{"salesChannel":"1"}',
|
|
101
|
+
locale: 'en-US',
|
|
102
|
+
b2b: { customerId: 'contract-137' },
|
|
103
|
+
})
|
|
104
|
+
mockRequest.mockRejectedValue({ status: 404, message: 'Not Found' })
|
|
105
|
+
|
|
106
|
+
renderHook(usePdpClientQuery, { wrapper: isolatedCache })
|
|
107
|
+
|
|
108
|
+
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/404'))
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('retries a transient failure instead of treating it as not found', async () => {
|
|
112
|
+
mockUseSession.mockReturnValue({
|
|
113
|
+
channel: '{"salesChannel":"1"}',
|
|
114
|
+
locale: 'en-US',
|
|
115
|
+
b2b: { customerId: 'contract-137' },
|
|
116
|
+
})
|
|
117
|
+
const fresh = { product: { id: '123', name: 'Fresh' } }
|
|
118
|
+
mockRequest
|
|
119
|
+
.mockRejectedValueOnce({ status: 500, message: 'Internal Server Error' })
|
|
120
|
+
.mockResolvedValue(fresh)
|
|
121
|
+
|
|
122
|
+
const fastRetries = ({ children }: { children: ReactNode }) =>
|
|
123
|
+
createElement(
|
|
124
|
+
SWRConfig,
|
|
125
|
+
{ value: { provider: () => new Map(), errorRetryInterval: 1 } },
|
|
126
|
+
children
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const { result } = renderHook(usePdpClientQuery, { wrapper: fastRetries })
|
|
130
|
+
|
|
131
|
+
await waitFor(() => expect(result.current.data).toEqual(fresh))
|
|
132
|
+
|
|
133
|
+
expect(mockRequest).toHaveBeenCalledTimes(2)
|
|
134
|
+
expect(mockSharedRetry).toHaveBeenCalledTimes(1)
|
|
135
|
+
expect(mockReplace).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('keeps the product and does not redirect when the refetch succeeds', async () => {
|
|
139
|
+
mockUseSession.mockReturnValue({
|
|
140
|
+
channel: '{"salesChannel":"1"}',
|
|
141
|
+
locale: 'en-US',
|
|
142
|
+
b2b: { customerId: 'contract-137' },
|
|
143
|
+
})
|
|
144
|
+
const fresh = { product: { id: '123', name: 'Fresh' } }
|
|
145
|
+
mockRequest.mockResolvedValue(fresh)
|
|
146
|
+
|
|
147
|
+
const { result } = renderHook(usePdpClientQuery, { wrapper: isolatedCache })
|
|
148
|
+
|
|
149
|
+
await waitFor(() => expect(result.current.data).toEqual(fresh))
|
|
150
|
+
|
|
151
|
+
expect(result.current.error).toBeUndefined()
|
|
152
|
+
expect(mockReplace).not.toHaveBeenCalled()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Session } from '@faststore/sdk'
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
vi,
|
|
10
|
+
} from 'vitest'
|
|
11
|
+
|
|
12
|
+
const mockRequest = vi.hoisted(() => vi.fn())
|
|
13
|
+
const mockRefreshTokenRequest = vi.hoisted(() => vi.fn())
|
|
14
|
+
|
|
15
|
+
vi.mock('../../../src/sdk/graphql/request', () => ({
|
|
16
|
+
request: mockRequest,
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
// Keeps the real `isB2BSession`, so the route choice is exercised end to end.
|
|
20
|
+
vi.mock('../../../src/sdk/account/refreshToken', async (importOriginal) => ({
|
|
21
|
+
...(await importOriginal<
|
|
22
|
+
typeof import('../../../src/sdk/account/refreshToken')
|
|
23
|
+
>()),
|
|
24
|
+
refreshTokenRequest: mockRefreshTokenRequest,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import storeConfig from '../../../discovery.config'
|
|
28
|
+
import { validateSession } from '../../../src/sdk/session'
|
|
29
|
+
|
|
30
|
+
// An already expired `refreshAfter` (in seconds) triggers the refresh.
|
|
31
|
+
const expiredSession: Session = {
|
|
32
|
+
locale: 'en-US',
|
|
33
|
+
currency: { code: 'USD', symbol: '$' },
|
|
34
|
+
country: 'USA',
|
|
35
|
+
channel: '{"salesChannel":"1","regionId":""}',
|
|
36
|
+
deliveryMode: null,
|
|
37
|
+
addressType: null,
|
|
38
|
+
city: null,
|
|
39
|
+
postalCode: '10001',
|
|
40
|
+
geoCoordinates: null,
|
|
41
|
+
person: null,
|
|
42
|
+
b2b: null,
|
|
43
|
+
marketingData: null,
|
|
44
|
+
refreshAfter: '1',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const withB2b = (b2b: Partial<NonNullable<Session['b2b']>>) =>
|
|
48
|
+
({ ...expiredSession, b2b }) as Session
|
|
49
|
+
|
|
50
|
+
// The store validates its initial session when the module loads. Let that
|
|
51
|
+
// call settle once, so no test depends on calls left by another one.
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
mockRequest.mockResolvedValue({ validateSession: null })
|
|
54
|
+
await vi.waitFor(() => expect(mockRequest).toHaveBeenCalled())
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('validateSession refresh route', () => {
|
|
58
|
+
const originalLocation = window.location
|
|
59
|
+
const originalRefreshToken = storeConfig.experimental.refreshToken
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
// Local hosts skip the refresh, so run on a production host.
|
|
63
|
+
Object.defineProperty(window, 'location', {
|
|
64
|
+
configurable: true,
|
|
65
|
+
value: { ...originalLocation, hostname: 'brandless.fast.store' },
|
|
66
|
+
})
|
|
67
|
+
storeConfig.experimental.refreshToken = true
|
|
68
|
+
mockRequest.mockReset()
|
|
69
|
+
mockRequest.mockResolvedValue({ validateSession: null })
|
|
70
|
+
mockRefreshTokenRequest.mockReset()
|
|
71
|
+
mockRefreshTokenRequest.mockResolvedValue({
|
|
72
|
+
authStatus: 'Success',
|
|
73
|
+
refreshAfter: Math.floor(Date.now() / 1000) + 3600,
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
storeConfig.experimental.refreshToken = originalRefreshToken
|
|
79
|
+
Object.defineProperty(window, 'location', {
|
|
80
|
+
configurable: true,
|
|
81
|
+
value: originalLocation,
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('uses the Authenticator route for a buyer with a unit and a contract', async () => {
|
|
86
|
+
await validateSession(
|
|
87
|
+
withB2b({ unitId: 'unit-1', customerId: 'contract-1' })
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(mockRefreshTokenRequest).toHaveBeenCalledWith({ isB2B: true })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('keeps the legacy route when `b2b` has no unit or contract', async () => {
|
|
94
|
+
// `b2b` exists for every isRepresentative token, which can be true
|
|
95
|
+
// outside B2B.
|
|
96
|
+
await validateSession(withB2b({ unitId: '', customerId: '' }))
|
|
97
|
+
|
|
98
|
+
expect(mockRefreshTokenRequest).toHaveBeenCalledWith({ isB2B: false })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps the legacy route without a B2B session', async () => {
|
|
102
|
+
await validateSession(expiredSession)
|
|
103
|
+
|
|
104
|
+
expect(mockRefreshTokenRequest).toHaveBeenCalledWith({ isB2B: false })
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import type { Session } from '@faststore/sdk'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
vi,
|
|
10
|
+
} from 'vitest'
|
|
3
11
|
|
|
4
12
|
const mockRequest = vi.hoisted(() => vi.fn())
|
|
13
|
+
const mockRefreshTokenRequest = vi.hoisted(() => vi.fn())
|
|
5
14
|
|
|
6
15
|
vi.mock('../../../src/sdk/graphql/request', () => ({
|
|
7
16
|
request: mockRequest,
|
|
8
17
|
}))
|
|
9
18
|
|
|
19
|
+
vi.mock('../../../src/sdk/account/refreshToken', async (importOriginal) => ({
|
|
20
|
+
...(await importOriginal<
|
|
21
|
+
typeof import('../../../src/sdk/account/refreshToken')
|
|
22
|
+
>()),
|
|
23
|
+
refreshTokenRequest: mockRefreshTokenRequest,
|
|
24
|
+
isRefreshTokenSuccessful: () => true,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import storeConfig from '../../../discovery.config'
|
|
10
28
|
import { validateSession } from '../../../src/sdk/session'
|
|
11
29
|
|
|
12
30
|
const session: Session = {
|
|
@@ -25,12 +43,17 @@ const session: Session = {
|
|
|
25
43
|
refreshAfter: null,
|
|
26
44
|
}
|
|
27
45
|
|
|
46
|
+
// The store validates its initial session when the module loads. Let that
|
|
47
|
+
// call settle once, so no test depends on calls left by another one.
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
mockRequest.mockResolvedValue({ validateSession: null })
|
|
50
|
+
await vi.waitFor(() => expect(mockRequest).toHaveBeenCalled())
|
|
51
|
+
})
|
|
52
|
+
|
|
28
53
|
describe('validateSession', () => {
|
|
29
|
-
beforeEach(
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
mockRequest.mockReset()
|
|
30
56
|
mockRequest.mockResolvedValue({ validateSession: null })
|
|
31
|
-
// The store validates its initial session on load; let it settle first
|
|
32
|
-
await vi.waitFor(() => expect(mockRequest).toHaveBeenCalled())
|
|
33
|
-
mockRequest.mockClear()
|
|
34
57
|
})
|
|
35
58
|
|
|
36
59
|
it('does not send the UI state persisted with the session', async () => {
|
|
@@ -45,3 +68,86 @@ describe('validateSession', () => {
|
|
|
45
68
|
expect(mockRequest.mock.calls[0][1].session).toStrictEqual(session)
|
|
46
69
|
})
|
|
47
70
|
})
|
|
71
|
+
|
|
72
|
+
describe('validateSession refresh-token bypass', () => {
|
|
73
|
+
const originalLocation = window.location
|
|
74
|
+
const originalRefreshToken = storeConfig.experimental.refreshToken
|
|
75
|
+
|
|
76
|
+
// An already expired `refreshAfter` (in seconds) triggers the refresh path.
|
|
77
|
+
const expiredSession: Session = { ...session, refreshAfter: '1' }
|
|
78
|
+
|
|
79
|
+
function stubHostname(hostname: string) {
|
|
80
|
+
// jsdom forbids assigning to `window.location` directly, so we redefine the
|
|
81
|
+
// descriptor with a minimal stub that exposes the hostname we want to test.
|
|
82
|
+
Object.defineProperty(window, 'location', {
|
|
83
|
+
configurable: true,
|
|
84
|
+
value: { ...originalLocation, hostname },
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
mockRequest.mockReset()
|
|
90
|
+
mockRequest.mockResolvedValue({ validateSession: null })
|
|
91
|
+
mockRefreshTokenRequest.mockReset()
|
|
92
|
+
mockRefreshTokenRequest.mockResolvedValue({
|
|
93
|
+
status: 'success',
|
|
94
|
+
refreshAfter: new Date(Date.now() + 60_000).toISOString(),
|
|
95
|
+
})
|
|
96
|
+
storeConfig.experimental.refreshToken = true
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
storeConfig.experimental.refreshToken = originalRefreshToken
|
|
101
|
+
Object.defineProperty(window, 'location', {
|
|
102
|
+
configurable: true,
|
|
103
|
+
value: originalLocation,
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it.each(['localhost', 'brandless-abc123.preview.vtex.app'])(
|
|
108
|
+
'skips the refresh when refreshAfter is expired on %s',
|
|
109
|
+
async (hostname) => {
|
|
110
|
+
stubHostname(hostname)
|
|
111
|
+
|
|
112
|
+
await validateSession(expiredSession)
|
|
113
|
+
|
|
114
|
+
expect(mockRefreshTokenRequest).not.toHaveBeenCalled()
|
|
115
|
+
expect(mockRequest).toHaveBeenCalledTimes(1)
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
it.each(['localhost', 'brandless-abc123.preview.vtex.app'])(
|
|
120
|
+
'skips the refresh when ValidateSession answers 401 on %s',
|
|
121
|
+
async (hostname) => {
|
|
122
|
+
stubHostname(hostname)
|
|
123
|
+
mockRequest.mockRejectedValueOnce({ status: 401 })
|
|
124
|
+
|
|
125
|
+
await validateSession(session)
|
|
126
|
+
|
|
127
|
+
expect(mockRefreshTokenRequest).not.toHaveBeenCalled()
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
it.each(['brandless.vtex.app', 'brandless.fast.store'])(
|
|
132
|
+
'refreshes the token when refreshAfter is expired on %s',
|
|
133
|
+
async (hostname) => {
|
|
134
|
+
stubHostname(hostname)
|
|
135
|
+
|
|
136
|
+
await validateSession(expiredSession)
|
|
137
|
+
|
|
138
|
+
expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1)
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
it.each(['brandless.vtex.app', 'brandless.fast.store'])(
|
|
143
|
+
'refreshes the token when ValidateSession answers 401 on %s',
|
|
144
|
+
async (hostname) => {
|
|
145
|
+
stubHostname(hostname)
|
|
146
|
+
mockRequest.mockRejectedValueOnce({ status: 401 })
|
|
147
|
+
|
|
148
|
+
await validateSession(session)
|
|
149
|
+
|
|
150
|
+
expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1)
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
})
|