@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.
Files changed (79) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +278 -235
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +86 -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
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
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(async () => {
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
+ })