@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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +23 -23
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/webpack/client-production/0.pack +2 -2
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/0.pack +2 -2
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/images-manifest.json +1 -1
- package/.next/prerender-manifest.js +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/react-loadable-manifest.json +2 -2
- package/.next/required-server-files.json +1 -1
- package/.next/routes-manifest.json +1 -1
- package/.next/server/chunks/948.js +1 -1
- package/.next/server/chunks/9563.js +3 -3
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/api/graphql.js +3 -3
- package/.next/server/pages/api/preview.js +1 -1
- package/.next/server/pages/en-US/404.html +2 -2
- package/.next/server/pages/en-US/500.html +2 -2
- package/.next/server/pages/en-US/checkout.html +2 -2
- package/.next/server/pages/en-US/login.html +2 -2
- package/.next/server/pages/en-US/password-protection.html +2 -2
- package/.next/server/pages/en-US/s.html +2 -2
- package/.next/server/pages/en-US.html +2 -2
- package/.next/server/pages-manifest.json +1 -1
- package/.next/server/src/middleware.js +3 -3
- package/.next/static/{Oxei21wY_TrPYtPGUrFiR → bnfahun8MCxDDpW52hKXX}/_buildManifest.js +1 -1
- package/.next/static/chunks/7783.017603f0dd9e1dd7.js +1 -0
- package/.next/static/chunks/pages/{[...slug]-cf9e3bf4febd21c3.js → [...slug]-d2b8abf5b0c0bb19.js} +1 -1
- package/.next/static/chunks/{webpack-bde714c56fd09a0c.js → webpack-62bcb2a452e15476.js} +1 -1
- package/.next/trace +156 -156
- package/.turbo/turbo-build.log +160 -165
- package/CHANGELOG.md +13 -0
- package/package.json +3 -3
- 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 +56 -38
- package/test/server/cms/index.test.ts +112 -8
- package/test/server/content/promiseCache.test.ts +150 -0
- package/test/server/content/service.test.ts +229 -5
- package/.next/cache/config.json +0 -7
- package/.next/static/chunks/3802.9744a010fe6a4d07.js +0 -1
- package/.turbo/turbo-test.log +0 -23
- /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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
6
|
+
import {
|
|
7
|
+
type ContentParams,
|
|
8
|
+
ContentSourceType,
|
|
9
|
+
} from '../../../src/server/content/types'
|
|
5
10
|
|
|
6
|
-
jest.mock('../../../
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
})
|