@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
@@ -1,5 +1,10 @@
1
- import { vi } from 'vitest'
2
- import { clientCMS, getCMSPage } from '../../../src/server/cms'
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ clientCMS,
4
+ cmsPageCache,
5
+ getCMSPage,
6
+ getPage,
7
+ } from '../../../src/server/cms'
3
8
  import type { ContentData } from '@vtex/client-cms'
4
9
 
5
10
  describe('CMS Integration', () => {
@@ -18,15 +23,22 @@ describe('CMS Integration', () => {
18
23
  return data
19
24
  }
20
25
 
26
+ const mockListing = (count = 1) =>
27
+ vi.fn((_contentType: string) =>
28
+ Promise.resolve({
29
+ data: mockData(count),
30
+ hasNextPage: false,
31
+ totalItems: count,
32
+ })
33
+ )
34
+
35
+ beforeEach(() => {
36
+ cmsPageCache.clear()
37
+ })
38
+
21
39
  describe('getCMSPage', () => {
22
40
  it('returns the first page if there is only one page', async () => {
23
- const mockFunction = vi.fn(() => {
24
- return Promise.resolve({
25
- data: mockData(3),
26
- hasNextPage: false,
27
- totalItems: 3,
28
- })
29
- })
41
+ const mockFunction = mockListing(3)
30
42
  clientCMS.getCMSPagesByContentType = mockFunction
31
43
 
32
44
  const result = await getCMSPage({ contentType: 'plp' }, clientCMS)
@@ -61,4 +73,85 @@ describe('CMS Integration', () => {
61
73
  expect(result.data.length).toBe(15)
62
74
  })
63
75
  })
76
+
77
+ describe('cache', () => {
78
+ it('shares a content type listing across concurrent callers', async () => {
79
+ const mockFunction = mockListing(3)
80
+ clientCMS.getCMSPagesByContentType = mockFunction
81
+
82
+ await Promise.all([
83
+ getCMSPage({ contentType: 'plp' }),
84
+ getCMSPage({ contentType: 'plp' }),
85
+ getCMSPage({ contentType: 'plp' }),
86
+ ])
87
+ await getCMSPage({ contentType: 'pdp' })
88
+
89
+ expect(
90
+ mockFunction.mock.calls.map(([contentType]) => contentType)
91
+ ).toEqual(['plp', 'pdp'])
92
+ })
93
+
94
+ it('does not cache filtered listings', async () => {
95
+ const mockFunction = mockListing(1)
96
+ clientCMS.getCMSPagesByContentType = mockFunction
97
+ const options = {
98
+ contentType: 'landingPage',
99
+ filters: { 'settings.seo.slug': '/summer-sale' },
100
+ }
101
+
102
+ await getCMSPage(options)
103
+ await getCMSPage(options)
104
+
105
+ expect(mockFunction).toHaveBeenCalledTimes(2)
106
+ })
107
+
108
+ it('fetches a locator on every call by default', async () => {
109
+ const mockFunction = vi.fn(() => Promise.resolve(mockData(1)[0]))
110
+ clientCMS.getCMSPage = mockFunction
111
+ const locator = {
112
+ contentType: 'globalSections',
113
+ documentId: 'doc-1',
114
+ versionId: 'version-1',
115
+ }
116
+
117
+ await getPage(locator)
118
+ await getPage(locator)
119
+
120
+ expect(mockFunction).toHaveBeenCalledTimes(2)
121
+ })
122
+
123
+ it('reuses a locator fetch when caching is enabled', async () => {
124
+ const mockFunction = vi.fn(() => Promise.resolve(mockData(1)[0]))
125
+ clientCMS.getCMSPage = mockFunction
126
+ const locator = {
127
+ contentType: 'globalSections',
128
+ documentId: 'doc-1',
129
+ versionId: 'version-1',
130
+ }
131
+
132
+ const [first, second] = await Promise.all([
133
+ getPage(locator, { cache: true }),
134
+ getPage(locator, { cache: true }),
135
+ ])
136
+ await getPage({ ...locator, versionId: 'version-2' }, { cache: true })
137
+
138
+ expect(first).toEqual(second)
139
+ expect(mockFunction).toHaveBeenCalledTimes(2)
140
+ })
141
+
142
+ it('does not cache failures', async () => {
143
+ const error = new Error('Request failed with status code 500')
144
+ const mockFunction = vi
145
+ .fn()
146
+ .mockRejectedValueOnce(error)
147
+ .mockResolvedValueOnce({ data: mockData(1), totalItems: 1 })
148
+ clientCMS.getCMSPagesByContentType = mockFunction
149
+
150
+ await expect(getCMSPage({ contentType: 'plp' })).rejects.toBe(error)
151
+ const result = await getCMSPage({ contentType: 'plp' })
152
+
153
+ expect(result.data).toHaveLength(1)
154
+ expect(mockFunction).toHaveBeenCalledTimes(2)
155
+ })
156
+ })
64
157
  })
@@ -0,0 +1,150 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createPromiseCache } from '../../../src/server/content/promiseCache'
4
+
5
+ describe('createPromiseCache', () => {
6
+ afterEach(() => {
7
+ vi.useRealTimers()
8
+ })
9
+
10
+ it('shares one in-flight load across concurrent callers', async () => {
11
+ const cache = createPromiseCache<string>(1000)
12
+ const load = vi.fn(async () => 'value')
13
+
14
+ const results = await Promise.all([
15
+ cache.get('key', load),
16
+ cache.get('key', load),
17
+ cache.get('key', load),
18
+ ])
19
+
20
+ expect(load).toHaveBeenCalledTimes(1)
21
+ expect(results).toEqual(['value', 'value', 'value'])
22
+ })
23
+
24
+ it('keeps separate entries per key', async () => {
25
+ const cache = createPromiseCache<string>(1000)
26
+ const load = vi.fn(async () => 'value')
27
+
28
+ await cache.get('a', load)
29
+ await cache.get('b', load)
30
+
31
+ expect(load).toHaveBeenCalledTimes(2)
32
+ })
33
+
34
+ it('loads again after the TTL', async () => {
35
+ vi.useFakeTimers()
36
+ const cache = createPromiseCache<string>(1000)
37
+ const load = vi.fn(async () => 'value')
38
+
39
+ await cache.get('key', load)
40
+ vi.advanceTimersByTime(999)
41
+ await cache.get('key', load)
42
+ expect(load).toHaveBeenCalledTimes(1)
43
+
44
+ vi.advanceTimersByTime(1)
45
+ await cache.get('key', load)
46
+ expect(load).toHaveBeenCalledTimes(2)
47
+ })
48
+
49
+ it('does not cache failures', async () => {
50
+ const cache = createPromiseCache<string>(1000)
51
+ const error = new Error('boom')
52
+ const load = vi
53
+ .fn<() => Promise<string>>()
54
+ .mockRejectedValueOnce(error)
55
+ .mockResolvedValueOnce('value')
56
+
57
+ await expect(cache.get('key', load)).rejects.toBe(error)
58
+ await expect(cache.get('key', load)).resolves.toBe('value')
59
+ expect(load).toHaveBeenCalledTimes(2)
60
+ })
61
+
62
+ it('does not let a late failure evict a newer load', async () => {
63
+ vi.useFakeTimers()
64
+ const cache = createPromiseCache<string>(1000)
65
+ let rejectFirst: (err: Error) => void = () => {}
66
+ const load = vi
67
+ .fn<() => Promise<string>>()
68
+ .mockImplementationOnce(
69
+ () =>
70
+ new Promise((_, reject) => {
71
+ rejectFirst = reject
72
+ })
73
+ )
74
+ .mockResolvedValue('value')
75
+
76
+ const first = cache.get('key', load)
77
+ const firstResult = expect(first).rejects.toThrow('late failure')
78
+ vi.advanceTimersByTime(1000)
79
+ await cache.get('key', load)
80
+
81
+ rejectFirst(new Error('late failure'))
82
+ await firstResult
83
+ await cache.get('key', load)
84
+
85
+ expect(load).toHaveBeenCalledTimes(2)
86
+ })
87
+
88
+ it('does not keep values rejected by shouldCache', async () => {
89
+ const cache = createPromiseCache<string>(1000)
90
+ const load = vi.fn(async () => '')
91
+ const shouldCache = (value: string) => value !== ''
92
+
93
+ const concurrent = await Promise.all([
94
+ cache.get('key', load, shouldCache),
95
+ cache.get('key', load, shouldCache),
96
+ ])
97
+ await cache.get('key', load, shouldCache)
98
+
99
+ expect(concurrent).toEqual(['', ''])
100
+ expect(load).toHaveBeenCalledTimes(2)
101
+ })
102
+
103
+ it('keeps values accepted by shouldCache', async () => {
104
+ const cache = createPromiseCache<string>(1000)
105
+ const load = vi.fn(async () => 'value')
106
+
107
+ await cache.get('key', load, (value) => value !== '')
108
+ await cache.get('key', load, (value) => value !== '')
109
+
110
+ expect(load).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ it('drops expired entries when a new key is stored', async () => {
114
+ vi.useFakeTimers()
115
+ const cache = createPromiseCache<string>(1000)
116
+ const load = vi.fn(async () => 'value')
117
+
118
+ await cache.get('old-version', load)
119
+ await cache.get('other', load)
120
+ expect(cache.size).toBe(2)
121
+
122
+ vi.advanceTimersByTime(1000)
123
+ await cache.get('new-version', load)
124
+
125
+ expect(cache.size).toBe(1)
126
+ })
127
+
128
+ it('keeps entries that have not expired when a new key is stored', async () => {
129
+ vi.useFakeTimers()
130
+ const cache = createPromiseCache<string>(1000)
131
+ const load = vi.fn(async () => 'value')
132
+
133
+ await cache.get('a', load)
134
+ vi.advanceTimersByTime(999)
135
+ await cache.get('b', load)
136
+
137
+ expect(cache.size).toBe(2)
138
+ })
139
+
140
+ it('drops every entry on clear', async () => {
141
+ const cache = createPromiseCache<string>(1000)
142
+ const load = vi.fn(async () => 'value')
143
+
144
+ await cache.get('key', load)
145
+ cache.clear()
146
+ await cache.get('key', load)
147
+
148
+ expect(load).toHaveBeenCalledTimes(2)
149
+ })
150
+ })
@@ -1,22 +1,45 @@
1
1
  import type { ContentEntry, EntryPathParams } from '@vtex/client-cp'
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
3
 
4
+ import { clientCMS, cmsPageCache } from '../../../src/server/cms'
4
5
  import { ContentService } from '../../../src/server/content/service'
5
- import type {
6
- ContentOptions,
7
- ContentParams,
6
+ import {
7
+ type ContentOptions,
8
+ type ContentParams,
9
+ ContentSourceType,
8
10
  } from '../../../src/server/content/types'
9
- import type * as ContentUtils from '../../../src/server/content/utils'
10
- import { isContentPlatformSource } from '../../../src/server/content/utils'
11
+ import { contentSource } from '../../../discovery.config'
12
+
13
+ type ContentSourceConfig = { type: string }
14
+ type DiscoveryConfigModule = {
15
+ default: Record<string, unknown>
16
+ contentSource: ContentSourceConfig
17
+ }
18
+
19
+ vi.mock('../../../discovery.config', async (importOriginal) => {
20
+ const actual = await importOriginal<DiscoveryConfigModule>()
21
+ const base = actual.default ?? actual
22
+ const mutableContentSource = {
23
+ ...(base.contentSource as ContentSourceConfig),
24
+ }
11
25
 
12
- vi.mock('../../../src/server/content/utils', async (importOriginal) => {
13
- const actual = await importOriginal<typeof ContentUtils>()
14
26
  return {
15
27
  ...actual,
16
- isContentPlatformSource: vi.fn(actual.isContentPlatformSource),
28
+ default: { ...base, contentSource: mutableContentSource },
29
+ contentSource: mutableContentSource,
17
30
  }
18
31
  })
19
32
 
33
+ const DEFAULT_CONTENT_SOURCE_TYPE = contentSource.type
34
+
35
+ const useContentPlatform = () => {
36
+ contentSource.type = ContentSourceType.ContentPlatform
37
+ }
38
+
39
+ const useHeadlessCms = () => {
40
+ contentSource.type = DEFAULT_CONTENT_SOURCE_TYPE
41
+ }
42
+
20
43
  type ContentServiceInternals = {
21
44
  createContentOptions: (params: ContentParams) => ContentOptions
22
45
  convertOptionsToParams: (options: ContentOptions) => EntryPathParams
@@ -39,7 +62,17 @@ const entryData: Record<string, Record<string, unknown>> = {
39
62
  },
40
63
  }
41
64
 
42
- const toEntry = (id: string) => ({ id, name: id }) as ContentEntry
65
+ const toEntry = (id: string): ContentEntry => ({
66
+ id,
67
+ name: id,
68
+ account: 'example-account',
69
+ storeId: 'faststore',
70
+ createdAt: '2026-01-01T00:00:00.000Z',
71
+ updatedAt: '2026-01-02T00:00:00.000Z',
72
+ createdBy: 'editor@example.com',
73
+ updatedBy: 'other@example.com',
74
+ slug: id,
75
+ })
43
76
 
44
77
  function createFakeClientCP(pages: string[][]) {
45
78
  return {
@@ -52,6 +85,8 @@ function createFakeClientCP(pages: string[][]) {
52
85
  }),
53
86
  getEntry: vi.fn(async ({ entryId }: EntryPathParams) => ({
54
87
  ...entryData[entryId as string],
88
+ createdBy: 'payload@example.com',
89
+ updatedBy: 'payload@example.com',
55
90
  })),
56
91
  previewEntryById: vi.fn(async ({ entryId }: EntryPathParams) => ({
57
92
  ...entryData[entryId as string],
@@ -163,16 +198,43 @@ describe('ContentService', () => {
163
198
  const FIVE_MINUTES = 5 * 60 * 1000
164
199
 
165
200
  beforeEach(() => {
166
- vi.mocked(isContentPlatformSource).mockReturnValue(true)
201
+ useContentPlatform()
167
202
  vi.spyOn(console, 'warn').mockImplementation(() => {})
168
203
  })
169
204
 
170
205
  afterEach(() => {
171
- vi.mocked(isContentPlatformSource).mockReset()
206
+ useHeadlessCms()
172
207
  vi.restoreAllMocks()
173
208
  vi.useRealTimers()
174
209
  })
175
210
 
211
+ it('keeps Content Platform author emails out of public page content', async () => {
212
+ const { service, clientCP } = createServiceWithClient()
213
+ clientCP.getEntryBySlug.mockResolvedValueOnce({
214
+ sections: [{ name: 'Direct' }],
215
+ createdBy: 'editor@example.com',
216
+ updatedBy: 'other@example.com',
217
+ })
218
+
219
+ const directPage = await service.getSingleContent({
220
+ contentType: 'pdp',
221
+ slug: 'product-slug',
222
+ })
223
+ const { data } = await service.getMultipleContent({ contentType: 'pdp' })
224
+ const listedPage = data[0]
225
+
226
+ expect(directPage.sections).toEqual([{ name: 'Direct' }])
227
+ expect(directPage).not.toHaveProperty('createdBy')
228
+ expect(directPage).not.toHaveProperty('updatedBy')
229
+ expect(listedPage.id).toBe('generic')
230
+ expect(listedPage.name).toBe('generic')
231
+ expect(listedPage.sections).toEqual([{ name: 'Generic' }])
232
+ expect(listedPage).not.toHaveProperty('createdBy')
233
+ expect(listedPage).not.toHaveProperty('updatedBy')
234
+ expect(listedPage).not.toHaveProperty('account')
235
+ expect(listedPage).not.toHaveProperty('storeId')
236
+ })
237
+
176
238
  it('shares one list and entry fetch across concurrent PLPs with different slugs', async () => {
177
239
  const { service, clientCP } = createServiceWithClient()
178
240
 
@@ -334,4 +396,208 @@ describe('ContentService', () => {
334
396
  expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
335
397
  })
336
398
  })
399
+
400
+ describe('Content Platform single entry cache', () => {
401
+ const FIVE_MINUTES = 5 * 60 * 1000
402
+
403
+ beforeEach(() => {
404
+ useContentPlatform()
405
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
406
+ })
407
+
408
+ afterEach(() => {
409
+ useHeadlessCms()
410
+ vi.restoreAllMocks()
411
+ vi.useRealTimers()
412
+ })
413
+
414
+ it('shares one list and entry fetch across concurrent pages', async () => {
415
+ const { service, clientCP } = createServiceWithClient([['generic']])
416
+
417
+ const results = await Promise.all(
418
+ Array.from({ length: 20 }, () =>
419
+ service.getSingleContent({ contentType: 'globalSections' })
420
+ )
421
+ )
422
+
423
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
424
+ expect(clientCP.getEntry).toHaveBeenCalledTimes(1)
425
+ for (const result of results) {
426
+ expect(result.sections).toEqual([{ name: 'Generic' }])
427
+ }
428
+ })
429
+
430
+ it('keeps separate entries per content type and locale', async () => {
431
+ const { service, clientCP } = createServiceWithClient([['generic']])
432
+
433
+ await service.getSingleContent({ contentType: 'globalSections' })
434
+ await service.getSingleContent({ contentType: 'globalHeaderSections' })
435
+ await service.getSingleContent({
436
+ contentType: 'globalSections',
437
+ locale: 'en-US',
438
+ })
439
+ await service.getSingleContent({ contentType: 'globalSections' })
440
+
441
+ expect(
442
+ clientCP.listEntries.mock.calls.map(([params]) => [
443
+ params.contentType,
444
+ params.locale,
445
+ ])
446
+ ).toEqual([
447
+ ['globalSections', undefined],
448
+ ['globalHeaderSections', undefined],
449
+ ['globalSections', 'en-US'],
450
+ ])
451
+ })
452
+
453
+ it('caches an empty optional content type', async () => {
454
+ const { service, clientCP } = createServiceWithClient([[]])
455
+
456
+ const first = await service.getSingleContent({
457
+ contentType: 'globalFooterSections',
458
+ })
459
+ await service.getSingleContent({ contentType: 'globalFooterSections' })
460
+
461
+ expect(first).toEqual({})
462
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
463
+ expect(clientCP.getEntry).not.toHaveBeenCalled()
464
+ })
465
+
466
+ it('does not cache an empty required content type', async () => {
467
+ const { service, clientCP } = createServiceWithClient([[]])
468
+
469
+ const first = await service.getSingleContent({
470
+ contentType: 'globalSections',
471
+ })
472
+ await service.getSingleContent({ contentType: 'globalSections' })
473
+
474
+ expect(first).toEqual({})
475
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
476
+ })
477
+
478
+ it('picks up a required content type published after an empty lookup', async () => {
479
+ const { service, clientCP } = createServiceWithClient([['generic']])
480
+ clientCP.listEntries.mockResolvedValueOnce({ entries: [], scroll: null })
481
+
482
+ const before = await service.getSingleContent({
483
+ contentType: 'globalSections',
484
+ })
485
+ const after = await service.getSingleContent({
486
+ contentType: 'globalSections',
487
+ })
488
+ await service.getSingleContent({ contentType: 'globalSections' })
489
+
490
+ expect(before).toEqual({})
491
+ expect(after.sections).toEqual([{ name: 'Generic' }])
492
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
493
+ })
494
+
495
+ it('does not cache lookups by slug', async () => {
496
+ const { service, clientCP } = createServiceWithClient([['generic']])
497
+
498
+ await service.getSingleContent({ contentType: 'landingPage', slug: 'a' })
499
+ await service.getSingleContent({ contentType: 'landingPage', slug: 'a' })
500
+
501
+ expect(clientCP.getEntryBySlug).toHaveBeenCalledTimes(2)
502
+ expect(clientCP.listEntries).not.toHaveBeenCalled()
503
+ })
504
+
505
+ it('bypasses the cache for branch previews', async () => {
506
+ const { service, clientCP } = createServiceWithClient([['generic']])
507
+ const branchPreview: ContentParams = {
508
+ contentType: 'globalSections',
509
+ previewData: {
510
+ contentType: 'landingPage',
511
+ documentId: 'some-landing-page',
512
+ versionId: 'branch-1',
513
+ },
514
+ }
515
+
516
+ await service.getSingleContent({ contentType: 'globalSections' })
517
+ await service.getSingleContent(branchPreview)
518
+ await service.getSingleContent(branchPreview)
519
+ await service.getSingleContent({ contentType: 'globalSections' })
520
+
521
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(3)
522
+ expect(clientCP.getEntry).toHaveBeenCalledTimes(1)
523
+ expect(clientCP.previewEntryById).toHaveBeenCalledTimes(2)
524
+ })
525
+
526
+ it('does not cache a multiple-entries error', async () => {
527
+ const { service, clientCP } = createServiceWithClient([
528
+ ['generic', 'collection'],
529
+ ])
530
+
531
+ await expect(
532
+ service.getSingleContent({ contentType: 'globalSections' })
533
+ ).rejects.toThrow()
534
+ await expect(
535
+ service.getSingleContent({ contentType: 'globalSections' })
536
+ ).rejects.toThrow()
537
+
538
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
539
+ })
540
+
541
+ it('fetches again after the TTL', async () => {
542
+ vi.useFakeTimers()
543
+ const { service, clientCP } = createServiceWithClient([['generic']])
544
+
545
+ await service.getSingleContent({ contentType: 'globalSections' })
546
+ vi.advanceTimersByTime(FIVE_MINUTES)
547
+ await service.getSingleContent({ contentType: 'globalSections' })
548
+
549
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
550
+ })
551
+ })
552
+
553
+ describe('Headless CMS single content cache', () => {
554
+ const locator = {
555
+ contentType: 'globalSections',
556
+ documentId: 'doc-1',
557
+ versionId: 'version-1',
558
+ }
559
+
560
+ beforeEach(() => {
561
+ useHeadlessCms()
562
+ cmsPageCache.clear()
563
+ vi.spyOn(clientCMS, 'getCMSPage').mockResolvedValue({
564
+ id: 'doc-1',
565
+ name: 'doc-1',
566
+ status: 'published',
567
+ type: 'globalSections',
568
+ releaseId: 'release-1',
569
+ sections: [{ name: 'Children' }],
570
+ } as Awaited<ReturnType<typeof clientCMS.getCMSPage>>)
571
+ })
572
+
573
+ afterEach(() => {
574
+ useHeadlessCms()
575
+ vi.restoreAllMocks()
576
+ })
577
+
578
+ it('reuses a published document fetched by locator', async () => {
579
+ const service = new ContentService()
580
+
581
+ await Promise.all([
582
+ service.getSingleContent(locator),
583
+ service.getSingleContent(locator),
584
+ ])
585
+ await service.getSingleContent(locator)
586
+
587
+ expect(clientCMS.getCMSPage).toHaveBeenCalledTimes(1)
588
+ })
589
+
590
+ it('fetches the document on every preview request', async () => {
591
+ const service = new ContentService()
592
+ const previewParams: ContentParams = {
593
+ ...locator,
594
+ previewData: locator,
595
+ }
596
+
597
+ await service.getSingleContent(previewParams)
598
+ await service.getSingleContent(previewParams)
599
+
600
+ expect(clientCMS.getCMSPage).toHaveBeenCalledTimes(2)
601
+ })
602
+ })
337
603
  })
@@ -0,0 +1,38 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { shouldBypassRefreshTokenBrowser } from '../../src/utils/shouldBypassRefreshToken'
3
+
4
+ const originalLocation = window.location
5
+
6
+ function stubHostname(hostname: string) {
7
+ // jsdom forbids assigning to `window.location` directly, so we redefine the
8
+ // descriptor with a minimal stub that exposes the hostname we want to test.
9
+ Object.defineProperty(window, 'location', {
10
+ configurable: true,
11
+ value: { ...originalLocation, hostname },
12
+ })
13
+ }
14
+
15
+ describe('shouldBypassRefreshTokenBrowser', () => {
16
+ afterEach(() => {
17
+ Object.defineProperty(window, 'location', {
18
+ configurable: true,
19
+ value: originalLocation,
20
+ })
21
+ })
22
+
23
+ it.each(['localhost', '127.0.0.1', 'brandless-abc123.preview.vtex.app'])(
24
+ 'returns true when window.location.hostname is "%s"',
25
+ (hostname) => {
26
+ stubHostname(hostname)
27
+ expect(shouldBypassRefreshTokenBrowser()).toBe(true)
28
+ }
29
+ )
30
+
31
+ it.each(['brandless.vtex.app', 'brandless.fast.store'])(
32
+ 'returns false for the production hostname "%s"',
33
+ (hostname) => {
34
+ stubHostname(hostname)
35
+ expect(shouldBypassRefreshTokenBrowser()).toBe(false)
36
+ }
37
+ )
38
+ })