@faststore/core 3.100.7 → 3.100.9

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 (51) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +23 -23
  3. package/.next/cache/.tsbuildinfo +1 -1
  4. package/.next/cache/webpack/client-production/0.pack +2 -2
  5. package/.next/cache/webpack/client-production/index.pack +0 -0
  6. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  7. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  8. package/.next/cache/webpack/server-production/0.pack +2 -2
  9. package/.next/cache/webpack/server-production/index.pack +0 -0
  10. package/.next/images-manifest.json +1 -1
  11. package/.next/prerender-manifest.js +1 -1
  12. package/.next/prerender-manifest.json +1 -1
  13. package/.next/react-loadable-manifest.json +2 -2
  14. package/.next/required-server-files.json +1 -1
  15. package/.next/routes-manifest.json +1 -1
  16. package/.next/server/chunks/948.js +1 -1
  17. package/.next/server/chunks/9563.js +3 -3
  18. package/.next/server/functions-config-manifest.json +1 -1
  19. package/.next/server/middleware-build-manifest.js +1 -1
  20. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  21. package/.next/server/pages/api/graphql.js +3 -3
  22. package/.next/server/pages/api/preview.js +1 -1
  23. package/.next/server/pages/en-US/404.html +2 -2
  24. package/.next/server/pages/en-US/500.html +2 -2
  25. package/.next/server/pages/en-US/checkout.html +2 -2
  26. package/.next/server/pages/en-US/login.html +2 -2
  27. package/.next/server/pages/en-US/password-protection.html +2 -2
  28. package/.next/server/pages/en-US/s.html +2 -2
  29. package/.next/server/pages/en-US.html +2 -2
  30. package/.next/server/pages-manifest.json +1 -1
  31. package/.next/server/src/middleware.js +3 -3
  32. package/.next/static/{Oxei21wY_TrPYtPGUrFiR → bnfahun8MCxDDpW52hKXX}/_buildManifest.js +1 -1
  33. package/.next/static/chunks/7783.017603f0dd9e1dd7.js +1 -0
  34. package/.next/static/chunks/pages/{[...slug]-cf9e3bf4febd21c3.js → [...slug]-d2b8abf5b0c0bb19.js} +1 -1
  35. package/.next/static/chunks/{webpack-bde714c56fd09a0c.js → webpack-62bcb2a452e15476.js} +1 -1
  36. package/.next/trace +156 -156
  37. package/.turbo/turbo-build.log +160 -165
  38. package/CHANGELOG.md +13 -0
  39. package/package.json +3 -3
  40. package/src/server/cms/index.ts +59 -7
  41. package/src/server/cms/pdp.ts +10 -5
  42. package/src/server/cms/plp.ts +10 -5
  43. package/src/server/content/promiseCache.ts +68 -0
  44. package/src/server/content/service.ts +56 -38
  45. package/test/server/cms/index.test.ts +112 -8
  46. package/test/server/content/promiseCache.test.ts +150 -0
  47. package/test/server/content/service.test.ts +229 -5
  48. package/.next/cache/config.json +0 -7
  49. package/.next/static/chunks/3802.9744a010fe6a4d07.js +0 -1
  50. package/.turbo/turbo-test.log +0 -23
  51. /package/.next/static/{Oxei21wY_TrPYtPGUrFiR → bnfahun8MCxDDpW52hKXX}/_ssgManifest.js +0 -0
@@ -15,23 +15,24 @@ import MissingContentError from 'src/sdk/error/MissingContentError'
15
15
  import { getPDP, type PDPContentType } from '../cms/pdp'
16
16
  import MultipleContentError from 'src/sdk/error/MultipleContentError'
17
17
  import type { ServerProductQueryQuery } from '@generated/graphql'
18
+ import { createPromiseCache } from './promiseCache'
18
19
  import { isBranchPreview, isContentPlatformSource } from './utils'
19
20
 
20
21
  type ContentResult = ContentData | (ContentEntry & PageContentType)
21
22
 
22
- type ContentListCacheEntry = {
23
- expiresAt: number
24
- promise: Promise<{ data: ContentResult[] }>
25
- }
26
-
27
- // Bounds how stale the template list can get in long-lived processes (ISR);
28
- // a CMS publish already triggers a new build.
29
- const CONTENT_LIST_CACHE_TTL_MS = 5 * 60 * 1000
23
+ const OPTIONAL_CONTENT_TYPES = [
24
+ 'globalHeaderSections',
25
+ 'globalFooterSections',
26
+ ] as const
30
27
 
31
28
  export class ContentService {
32
29
  private clientCP: ClientCP
33
30
 
34
- private readonly contentListCache = new Map<string, ContentListCacheEntry>()
31
+ private readonly contentListCache = createPromiseCache<{
32
+ data: ContentResult[]
33
+ }>()
34
+
35
+ private readonly firstEntryCache = createPromiseCache<PageContentType>()
35
36
 
36
37
  constructor() {
37
38
  this.clientCP = new ClientCP({
@@ -47,7 +48,7 @@ export class ContentService {
47
48
  if (isContentPlatformSource()) {
48
49
  return this.getFromCP<T>(options)
49
50
  }
50
- return getPage(options.cmsOptions)
51
+ return getPage(options.cmsOptions, { cache: !options.isPreview })
51
52
  }
52
53
 
53
54
  async getMultipleContent(
@@ -118,10 +119,15 @@ export class ContentService {
118
119
  options: ContentOptions
119
120
  ): Promise<T> {
120
121
  const params = this.convertOptionsToParams(options)
122
+ // Filters are forwarded to listEntries, so a filtered lookup is not shared.
123
+ const shareable =
124
+ !options.isPreview &&
125
+ !('filters' in options.cmsOptions && options.cmsOptions.filters)
121
126
  try {
122
127
  const entry: PageContentType = await this.getEntry(
123
128
  params,
124
- options.isPreview
129
+ options.isPreview,
130
+ shareable
125
131
  )
126
132
  return entry as T
127
133
  } catch (err: unknown) {
@@ -133,30 +139,9 @@ export class ContentService {
133
139
  private getCachedContentList(
134
140
  serviceParams: EntryPathParams
135
141
  ): Promise<{ data: ContentResult[] }> {
136
- // listEntries only depends on these fields; the page slug must stay out of the key.
137
- const { accountName, storeId, contentType, locale } = serviceParams
138
- const key = JSON.stringify([accountName, storeId, contentType, locale])
139
-
140
- const cached = this.contentListCache.get(key)
141
- if (cached && cached.expiresAt > Date.now()) {
142
- return cached.promise
143
- }
144
-
145
- const promise = this.listAndFillEntries(serviceParams, false).catch(
146
- (err: unknown) => {
147
- if (this.contentListCache.get(key)?.promise === promise) {
148
- this.contentListCache.delete(key)
149
- }
150
- throw err
151
- }
142
+ return this.contentListCache.get(getContentTypeKey(serviceParams), () =>
143
+ this.listAndFillEntries(serviceParams, false)
152
144
  )
153
-
154
- this.contentListCache.set(key, {
155
- expiresAt: Date.now() + CONTENT_LIST_CACHE_TTL_MS,
156
- promise,
157
- })
158
-
159
- return promise
160
145
  }
161
146
 
162
147
  private async listAndFillEntries(
@@ -203,11 +188,24 @@ export class ContentService {
203
188
 
204
189
  private async getEntry(
205
190
  params: EntryPathParams,
206
- isPreview: boolean
191
+ isPreview: boolean,
192
+ shareable: boolean
207
193
  ): Promise<PageContentType> {
208
- return params.entryId || params.slug
209
- ? await this.getEntryData(params, isPreview)
210
- : await this.fetchFirstEntryFromList(params, isPreview)
194
+ if (params.entryId || params.slug) {
195
+ return this.getEntryData(params, isPreview)
196
+ }
197
+
198
+ if (!shareable) {
199
+ return this.fetchFirstEntryFromList(params, isPreview)
200
+ }
201
+
202
+ // An empty required type is likely content that is about to be published,
203
+ // so it is not kept for the whole TTL. Optional types are empty by design.
204
+ return this.firstEntryCache.get(
205
+ getContentTypeKey(params),
206
+ () => this.fetchFirstEntryFromList(params, false),
207
+ (entry) => !isEmptyEntry(entry) || isOptionalContentType(params)
208
+ )
211
209
  }
212
210
 
213
211
  private async getEntryData(
@@ -338,6 +336,26 @@ export class ContentService {
338
336
  }
339
337
  }
340
338
 
339
+ function isOptionalContentType({ contentType }: EntryPathParams): boolean {
340
+ return OPTIONAL_CONTENT_TYPES.includes(
341
+ contentType as (typeof OPTIONAL_CONTENT_TYPES)[number]
342
+ )
343
+ }
344
+
345
+ function isEmptyEntry(entry: PageContentType): boolean {
346
+ return Object.keys(entry).length === 0
347
+ }
348
+
349
+ // listEntries only depends on these fields; the page slug must stay out of the key.
350
+ function getContentTypeKey({
351
+ accountName,
352
+ storeId,
353
+ contentType,
354
+ locale,
355
+ }: EntryPathParams): string {
356
+ return JSON.stringify([accountName, storeId, contentType, locale])
357
+ }
358
+
341
359
  function isNotFoundError(err: unknown): boolean {
342
360
  if (err instanceof MissingContentError) return true
343
361
  if (err instanceof Error && /\b404\b/.test(err.message)) return true
@@ -1,4 +1,9 @@
1
- import { clientCMS, getCMSPage } from '../../../src/server/cms'
1
+ import {
2
+ clientCMS,
3
+ cmsPageCache,
4
+ getCMSPage,
5
+ getPage,
6
+ } from '../../../src/server/cms'
2
7
  import { jest } from '@jest/globals'
3
8
  import type { ContentData } from '@vtex/client-cms'
4
9
 
@@ -18,15 +23,32 @@ describe('CMS Integration', () => {
18
23
  return data
19
24
  }
20
25
 
26
+ const mockListing = (count = 1) => {
27
+ const mockFunction: jest.Mock<typeof clientCMS.getCMSPagesByContentType> =
28
+ jest.fn()
29
+ mockFunction.mockImplementation(() =>
30
+ Promise.resolve({
31
+ data: mockData(count),
32
+ hasNextPage: false,
33
+ totalItems: count,
34
+ })
35
+ )
36
+ return mockFunction
37
+ }
38
+
39
+ const mockLocator = () => {
40
+ const mockFunction: jest.Mock<typeof clientCMS.getCMSPage> = jest.fn()
41
+ mockFunction.mockImplementation(() => Promise.resolve(mockData(1)[0]))
42
+ return mockFunction
43
+ }
44
+
45
+ beforeEach(() => {
46
+ cmsPageCache.clear()
47
+ })
48
+
21
49
  describe('getCMSPage', () => {
22
50
  it('returns the first page if there is only one page', async () => {
23
- const mockFunction = jest.fn(() => {
24
- return Promise.resolve({
25
- data: mockData(3),
26
- hasNextPage: false,
27
- totalItems: 3,
28
- })
29
- })
51
+ const mockFunction = mockListing(3)
30
52
  clientCMS.getCMSPagesByContentType = mockFunction
31
53
 
32
54
  const result = await getCMSPage({ contentType: 'plp' }, clientCMS)
@@ -62,4 +84,86 @@ describe('CMS Integration', () => {
62
84
  expect(result.data.length).toBe(15)
63
85
  })
64
86
  })
87
+
88
+ describe('cache', () => {
89
+ it('shares a content type listing across concurrent callers', async () => {
90
+ const mockFunction = mockListing(3)
91
+ clientCMS.getCMSPagesByContentType = mockFunction
92
+
93
+ await Promise.all([
94
+ getCMSPage({ contentType: 'plp' }),
95
+ getCMSPage({ contentType: 'plp' }),
96
+ getCMSPage({ contentType: 'plp' }),
97
+ ])
98
+ await getCMSPage({ contentType: 'pdp' })
99
+
100
+ expect(
101
+ mockFunction.mock.calls.map(([contentType]) => contentType)
102
+ ).toEqual(['plp', 'pdp'])
103
+ })
104
+
105
+ it('does not cache filtered listings', async () => {
106
+ const mockFunction = mockListing(1)
107
+ clientCMS.getCMSPagesByContentType = mockFunction
108
+ const options = {
109
+ contentType: 'landingPage',
110
+ filters: { filters: { 'settings.seo.slug': '/summer-sale' } },
111
+ }
112
+
113
+ await getCMSPage(options)
114
+ await getCMSPage(options)
115
+
116
+ expect(mockFunction).toHaveBeenCalledTimes(2)
117
+ })
118
+
119
+ it('fetches a locator on every call by default', async () => {
120
+ const mockFunction = mockLocator()
121
+ clientCMS.getCMSPage = mockFunction
122
+ const locator = {
123
+ contentType: 'globalSections',
124
+ documentId: 'doc-1',
125
+ versionId: 'version-1',
126
+ }
127
+
128
+ await getPage(locator)
129
+ await getPage(locator)
130
+
131
+ expect(mockFunction).toHaveBeenCalledTimes(2)
132
+ })
133
+
134
+ it('reuses a locator fetch when caching is enabled', async () => {
135
+ const mockFunction = mockLocator()
136
+ clientCMS.getCMSPage = mockFunction
137
+ const locator = {
138
+ contentType: 'globalSections',
139
+ documentId: 'doc-1',
140
+ versionId: 'version-1',
141
+ }
142
+
143
+ const [first, second] = await Promise.all([
144
+ getPage(locator, { cache: true }),
145
+ getPage(locator, { cache: true }),
146
+ ])
147
+ await getPage({ ...locator, versionId: 'version-2' }, { cache: true })
148
+
149
+ expect(first).toEqual(second)
150
+ expect(mockFunction).toHaveBeenCalledTimes(2)
151
+ })
152
+
153
+ it('does not cache failures', async () => {
154
+ const error = new Error('Request failed with status code 500')
155
+ const mockFunction: jest.Mock<typeof clientCMS.getCMSPagesByContentType> =
156
+ jest.fn()
157
+ mockFunction
158
+ .mockRejectedValueOnce(error)
159
+ .mockResolvedValueOnce({ data: mockData(1), totalItems: 1 } as never)
160
+ clientCMS.getCMSPagesByContentType = mockFunction
161
+
162
+ await expect(getCMSPage({ contentType: 'plp' })).rejects.toBe(error)
163
+ const result = await getCMSPage({ contentType: 'plp' })
164
+
165
+ expect(result.data).toHaveLength(1)
166
+ expect(mockFunction).toHaveBeenCalledTimes(2)
167
+ })
168
+ })
65
169
  })
@@ -0,0 +1,150 @@
1
+ import { jest } from '@jest/globals'
2
+
3
+ import { createPromiseCache } from '../../../src/server/content/promiseCache'
4
+
5
+ describe('createPromiseCache', () => {
6
+ afterEach(() => {
7
+ jest.useRealTimers()
8
+ })
9
+
10
+ it('shares one in-flight load across concurrent callers', async () => {
11
+ const cache = createPromiseCache<string>(1000)
12
+ const load = jest.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 = jest.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
+ jest.useFakeTimers()
36
+ const cache = createPromiseCache<string>(1000)
37
+ const load = jest.fn(async () => 'value')
38
+
39
+ await cache.get('key', load)
40
+ jest.advanceTimersByTime(999)
41
+ await cache.get('key', load)
42
+ expect(load).toHaveBeenCalledTimes(1)
43
+
44
+ jest.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 = jest
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
+ jest.useFakeTimers()
64
+ const cache = createPromiseCache<string>(1000)
65
+ let rejectFirst: (err: Error) => void = () => {}
66
+ const load = jest
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
+ jest.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 = jest.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 = jest.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
+ jest.useFakeTimers()
115
+ const cache = createPromiseCache<string>(1000)
116
+ const load = jest.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
+ jest.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
+ jest.useFakeTimers()
130
+ const cache = createPromiseCache<string>(1000)
131
+ const load = jest.fn(async () => 'value')
132
+
133
+ await cache.get('a', load)
134
+ jest.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 = jest.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,12 +1,35 @@
1
1
  import type { ContentEntry, EntryPathParams } from '@vtex/client-cp'
2
2
 
3
+ import { contentSource } from '../../../discovery.config'
4
+ import { clientCMS, cmsPageCache } from '../../../src/server/cms'
3
5
  import { ContentService } from '../../../src/server/content/service'
4
- import type { ContentParams } from '../../../src/server/content/types'
6
+ import {
7
+ type ContentParams,
8
+ ContentSourceType,
9
+ } from '../../../src/server/content/types'
5
10
 
6
- jest.mock('../../../src/server/content/utils', () => ({
7
- ...jest.requireActual('../../../src/server/content/utils'),
8
- isContentPlatformSource: jest.fn(() => true),
9
- }))
11
+ jest.mock('../../../discovery.config', () => {
12
+ const actual = jest.requireActual('../../../discovery.config')
13
+ const base = actual.default ?? actual
14
+ const mutableContentSource = { ...base.contentSource }
15
+
16
+ return {
17
+ __esModule: true,
18
+ ...actual,
19
+ default: { ...base, contentSource: mutableContentSource },
20
+ contentSource: mutableContentSource,
21
+ }
22
+ })
23
+
24
+ const DEFAULT_CONTENT_SOURCE_TYPE = contentSource.type
25
+
26
+ beforeEach(() => {
27
+ contentSource.type = ContentSourceType.ContentPlatform
28
+ })
29
+
30
+ afterAll(() => {
31
+ contentSource.type = DEFAULT_CONTENT_SOURCE_TYPE
32
+ })
10
33
 
11
34
  const NOT_FOUND = 'Request failed with status code 404'
12
35
  const FIVE_MINUTES = 5 * 60 * 1000
@@ -220,3 +243,204 @@ describe('ContentService Content Platform template list cache', () => {
220
243
  expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
221
244
  })
222
245
  })
246
+
247
+ describe('ContentService Content Platform single entry cache', () => {
248
+ beforeEach(() => {
249
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
250
+ })
251
+
252
+ afterEach(() => {
253
+ jest.restoreAllMocks()
254
+ jest.useRealTimers()
255
+ })
256
+
257
+ it('shares one list and entry fetch across concurrent pages', async () => {
258
+ const { service, clientCP } = createServiceWithClient([['generic']])
259
+
260
+ const results = await Promise.all(
261
+ Array.from({ length: 20 }, () =>
262
+ service.getSingleContent({ contentType: 'globalSections' })
263
+ )
264
+ )
265
+
266
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
267
+ expect(clientCP.getEntry).toHaveBeenCalledTimes(1)
268
+ for (const result of results) {
269
+ expect(result.sections).toEqual([{ name: 'Generic' }])
270
+ }
271
+ })
272
+
273
+ it('keeps separate entries per content type', async () => {
274
+ const { service, clientCP } = createServiceWithClient([['generic']])
275
+
276
+ await service.getSingleContent({ contentType: 'globalSections' })
277
+ await service.getSingleContent({ contentType: 'globalHeaderSections' })
278
+ await service.getSingleContent({ contentType: 'globalSections' })
279
+
280
+ expect(
281
+ clientCP.listEntries.mock.calls.map(([params]) => params.contentType)
282
+ ).toEqual(['globalSections', 'globalHeaderSections'])
283
+ })
284
+
285
+ it('caches an empty optional content type', async () => {
286
+ const { service, clientCP } = createServiceWithClient([[]])
287
+
288
+ const first = await service.getSingleContent({
289
+ contentType: 'globalFooterSections',
290
+ })
291
+ await service.getSingleContent({ contentType: 'globalFooterSections' })
292
+
293
+ expect(first).toEqual({})
294
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(1)
295
+ expect(clientCP.getEntry).not.toHaveBeenCalled()
296
+ })
297
+
298
+ it('does not cache an empty required content type', async () => {
299
+ const { service, clientCP } = createServiceWithClient([[]])
300
+
301
+ const first = await service.getSingleContent({
302
+ contentType: 'globalSections',
303
+ })
304
+ await service.getSingleContent({ contentType: 'globalSections' })
305
+
306
+ expect(first).toEqual({})
307
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
308
+ })
309
+
310
+ it('picks up a required content type published after an empty lookup', async () => {
311
+ const { service, clientCP } = createServiceWithClient([['generic']])
312
+ clientCP.listEntries.mockResolvedValueOnce({ entries: [], scroll: null })
313
+
314
+ const before = await service.getSingleContent({
315
+ contentType: 'globalSections',
316
+ })
317
+ const after = await service.getSingleContent({
318
+ contentType: 'globalSections',
319
+ })
320
+ await service.getSingleContent({ contentType: 'globalSections' })
321
+
322
+ expect(before).toEqual({})
323
+ expect(after.sections).toEqual([{ name: 'Generic' }])
324
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
325
+ })
326
+
327
+ it('does not cache lookups by slug', async () => {
328
+ const { service, clientCP } = createServiceWithClient([['generic']])
329
+
330
+ await service.getSingleContent({ contentType: 'landingPage', slug: 'a' })
331
+ await service.getSingleContent({ contentType: 'landingPage', slug: 'a' })
332
+
333
+ expect(clientCP.getEntryBySlug).toHaveBeenCalledTimes(2)
334
+ expect(clientCP.listEntries).not.toHaveBeenCalled()
335
+ })
336
+
337
+ it('does not cache filtered lookups', async () => {
338
+ const { service, clientCP } = createServiceWithClient([['generic']])
339
+ const params: ContentParams = {
340
+ contentType: 'globalSections',
341
+ filters: { page: 1 },
342
+ }
343
+
344
+ await service.getSingleContent(params)
345
+ await service.getSingleContent(params)
346
+
347
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
348
+ })
349
+
350
+ it('bypasses the cache for branch previews', async () => {
351
+ const { service, clientCP } = createServiceWithClient([['generic']])
352
+ const branchPreview: ContentParams = {
353
+ contentType: 'globalSections',
354
+ previewData: {
355
+ contentType: 'landingPage',
356
+ documentId: 'some-landing-page',
357
+ versionId: 'branch-1',
358
+ },
359
+ }
360
+
361
+ await service.getSingleContent({ contentType: 'globalSections' })
362
+ await service.getSingleContent(branchPreview)
363
+ await service.getSingleContent(branchPreview)
364
+ await service.getSingleContent({ contentType: 'globalSections' })
365
+
366
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(3)
367
+ expect(clientCP.getEntry).toHaveBeenCalledTimes(1)
368
+ expect(clientCP.previewEntryById).toHaveBeenCalledTimes(2)
369
+ })
370
+
371
+ it('does not cache a multiple-entries error', async () => {
372
+ const { service, clientCP } = createServiceWithClient([
373
+ ['generic', 'collection'],
374
+ ])
375
+
376
+ await expect(
377
+ service.getSingleContent({ contentType: 'globalSections' })
378
+ ).rejects.toThrow()
379
+ await expect(
380
+ service.getSingleContent({ contentType: 'globalSections' })
381
+ ).rejects.toThrow()
382
+
383
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
384
+ })
385
+
386
+ it('fetches again after the TTL', async () => {
387
+ jest.useFakeTimers()
388
+ const { service, clientCP } = createServiceWithClient([['generic']])
389
+
390
+ await service.getSingleContent({ contentType: 'globalSections' })
391
+ jest.advanceTimersByTime(FIVE_MINUTES)
392
+ await service.getSingleContent({ contentType: 'globalSections' })
393
+
394
+ expect(clientCP.listEntries).toHaveBeenCalledTimes(2)
395
+ })
396
+ })
397
+
398
+ describe('ContentService Headless CMS single content cache', () => {
399
+ const locator = {
400
+ contentType: 'globalSections',
401
+ documentId: 'doc-1',
402
+ versionId: 'version-1',
403
+ }
404
+
405
+ beforeEach(() => {
406
+ contentSource.type = DEFAULT_CONTENT_SOURCE_TYPE
407
+ cmsPageCache.clear()
408
+ jest.spyOn(clientCMS, 'getCMSPage').mockResolvedValue({
409
+ id: 'doc-1',
410
+ name: 'doc-1',
411
+ status: 'published',
412
+ type: 'globalSections',
413
+ releaseId: 'release-1',
414
+ sections: [{ name: 'Children' }],
415
+ } as Awaited<ReturnType<typeof clientCMS.getCMSPage>>)
416
+ })
417
+
418
+ afterEach(() => {
419
+ jest.restoreAllMocks()
420
+ })
421
+
422
+ it('reuses a published document fetched by locator', async () => {
423
+ const service = new ContentService()
424
+
425
+ await Promise.all([
426
+ service.getSingleContent(locator),
427
+ service.getSingleContent(locator),
428
+ ])
429
+ await service.getSingleContent(locator)
430
+
431
+ expect(clientCMS.getCMSPage).toHaveBeenCalledTimes(1)
432
+ })
433
+
434
+ it('fetches the document on every preview request', async () => {
435
+ const service = new ContentService()
436
+ const previewParams: ContentParams = {
437
+ ...locator,
438
+ previewData: locator,
439
+ }
440
+
441
+ await service.getSingleContent(previewParams)
442
+ await service.getSingleContent(previewParams)
443
+
444
+ expect(clientCMS.getCMSPage).toHaveBeenCalledTimes(2)
445
+ })
446
+ })
@@ -1,7 +0,0 @@
1
- {
2
- "telemetry": {
3
- "notifiedAt": "1793052786162",
4
- "anonymousId": "f9a3a0a50abf991b84a56c57c91a24593d9a11e7d1f9840e9a950611f78982b1",
5
- "salt": "4d3d2e48c23dc357f87442f0237d9ba6"
6
- }
7
- }