Skip to content

Commit 80e143c

Browse files
committed
test: add e2e tests for next@16 updateTag and revalidateTag with expiration profiles
1 parent 4672364 commit 80e143c

File tree

16 files changed

+705
-1
lines changed

16 files changed

+705
-1
lines changed

tests/e2e/on-demand-app.test.ts

Lines changed: 295 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
22
import { test } from '../utils/playwright-helpers.js'
33
import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
44

5-
test.describe('app router on-demand revalidation', () => {
5+
test.describe('app router on-demand revalidation (pre Next 16 APIs)', () => {
66
for (const { label, prerendered, pagePath, revalidateApiPath, expectedH1Content } of [
77
{
88
label: 'revalidatePath (prerendered page with static path)',
@@ -193,3 +193,297 @@ test.describe('app router on-demand revalidation', () => {
193193
})
194194
}
195195
})
196+
197+
if (nextVersionSatisfies('>=16.0.0-alpha.0')) {
198+
test.describe('app router on-demand revalidation (Next 16 APIs)', () => {
199+
for (const { label, prerendered, pagePathSuffix, tagSuffix, expectedH1Content } of [
200+
{
201+
label: 'prerendered page with static path',
202+
prerendered: true,
203+
pagePathSuffix: '/product-static',
204+
tagSuffix: 'product-static',
205+
expectedH1Content: 'Product product-static',
206+
},
207+
{
208+
label: 'prerendered page with dynamic path',
209+
prerendered: true,
210+
pagePathSuffix: '/product/prerendered',
211+
tagSuffix: 'prerendered',
212+
expectedH1Content: 'Product prerendered',
213+
},
214+
{
215+
label: 'not prerendered page with dynamic path',
216+
prerendered: false,
217+
pagePathSuffix: '/product/not-prerendered',
218+
tagSuffix: 'not-prerendered',
219+
expectedH1Content: 'Product not-prerendered',
220+
},
221+
]) {
222+
test.describe(label, () => {
223+
for (const { label, revalidateApiProfileSuffix, tagPrefix } of [
224+
{
225+
label: 'revalidateTag with string profile',
226+
revalidateApiProfileSuffix: `profile=testCacheLife`,
227+
tagPrefix: `revalidate-tag-string-profile`,
228+
},
229+
{
230+
label: 'revalidateTag with explicit inline expire',
231+
revalidateApiProfileSuffix: `expire=5`,
232+
tagPrefix: `revalidate-tag-explicit-inline-expire`,
233+
},
234+
]) {
235+
test(label, async ({ page, pollUntilHeadersMatch, next16TagRevalidation }) => {
236+
const pagePath = `/${tagPrefix}${pagePathSuffix}`
237+
const revalidateApiPath = `/api/revalidate-tag?tag=${tagPrefix}-${tagSuffix}&${revalidateApiProfileSuffix}`
238+
239+
// in case there is retry or some other test did hit that path before
240+
// we want to make sure that cdn cache is not warmed up
241+
const purgeCdnCache = await page.goto(
242+
new URL(`/api/purge-cdn?path=${pagePath}`, next16TagRevalidation.url).href,
243+
)
244+
expect(purgeCdnCache?.status()).toBe(200)
245+
246+
// wait a bit until cdn cache purge propagates
247+
await page.waitForTimeout(500)
248+
249+
const response1 = await pollUntilHeadersMatch(
250+
new URL(pagePath, next16TagRevalidation.url).href,
251+
{
252+
headersToMatch: {
253+
// either first time hitting this route or we invalidated
254+
// just CDN node in earlier step
255+
// we will invoke function and see Next cache hit status
256+
// in the response if it was prerendered at build time
257+
// or regenerated in previous attempt to run this test
258+
'cache-status': [
259+
/"Netlify Edge"; fwd=(miss|stale)/m,
260+
prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
261+
],
262+
},
263+
headersNotMatchedMessage:
264+
'First request to tested page should be a miss or stale on the Edge and hit in Next.js',
265+
},
266+
)
267+
const headers1 = response1?.headers() || {}
268+
expect(response1?.status()).toBe(200)
269+
expect(headers1['x-nextjs-cache']).toBeUndefined()
270+
expect(headers1['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable')
271+
272+
const date1 = await page.getByTestId('date-now').textContent()
273+
274+
const h1 = await page.locator('h1').textContent()
275+
expect(h1).toBe(expectedH1Content)
276+
277+
const response2 = await pollUntilHeadersMatch(
278+
new URL(pagePath, next16TagRevalidation.url).href,
279+
{
280+
headersToMatch: {
281+
// we are hitting the same page again and we most likely will see
282+
// CDN hit (in this case Next reported cache status is omitted
283+
// as it didn't actually take place in handling this request)
284+
// or we will see CDN miss because different CDN node handled request
285+
'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
286+
},
287+
headersNotMatchedMessage:
288+
'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
289+
},
290+
)
291+
const headers2 = response2?.headers() || {}
292+
expect(response2?.status()).toBe(200)
293+
expect(headers2['x-nextjs-cache']).toBeUndefined()
294+
if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) {
295+
// if we missed CDN cache, we will see Next cache hit status
296+
// as we reuse cached response
297+
expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m)
298+
}
299+
expect(headers2['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable')
300+
301+
// the page is cached
302+
const date2 = await page.getByTestId('date-now').textContent()
303+
expect(date2).toBe(date1)
304+
305+
const revalidate = await page.goto(
306+
new URL(revalidateApiPath, next16TagRevalidation.url).href,
307+
)
308+
expect(revalidate?.status()).toBe(200)
309+
310+
// wait a bit until cdn tags and invalidated and cdn is purged
311+
await page.waitForTimeout(500)
312+
313+
// now after the revalidation with delayed expiration, it should serve stale if we are still before expiration time was not reached
314+
const response3 = await pollUntilHeadersMatch(
315+
new URL(pagePath, next16TagRevalidation.url).href,
316+
{
317+
headersToMatch: {
318+
// revalidatePath just marks the page(s) as stale and does NOT
319+
// automatically refreshes the cache. This request should result
320+
// in serving stale content and trigger background revalidation.
321+
'cache-status': [
322+
/"Next.js"; hit; fwd=stale/m,
323+
/"Netlify Edge"; fwd=(miss|stale)/m,
324+
],
325+
},
326+
headersNotMatchedMessage:
327+
'Third request to tested page should be a miss or stale on the Edge and stale in Next.js after on-demand revalidation with delayed expiration',
328+
},
329+
)
330+
const headers3 = response3?.headers() || {}
331+
expect(response3?.status()).toBe(200)
332+
expect(headers3?.['x-nextjs-cache']).toBeUndefined()
333+
expect(headers3['debug-netlify-cdn-cache-control'], 'Stale is not cacheable').toBe(
334+
'public, max-age=0, must-revalidate, durable',
335+
)
336+
337+
// the page is stale but still served, because we hit it before expiration
338+
const date3 = await page.getByTestId('date-now').textContent()
339+
expect(date3).toBe(date2)
340+
341+
// previous request should trigger background revalidation. There is 5s sleep in data fetching in tested page
342+
// so let's wait for that
343+
344+
await page.waitForTimeout(6000)
345+
346+
const response4 = await pollUntilHeadersMatch(
347+
new URL(pagePath, next16TagRevalidation.url).href,
348+
{
349+
headersToMatch: {
350+
// we are hitting the same page again and we most likely will see
351+
// CDN hit (in this case Next reported cache status is omitted
352+
// as it didn't actually take place in handling this request)
353+
// or we will see CDN miss because different CDN node handled request
354+
'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
355+
},
356+
headersNotMatchedMessage:
357+
'Fourth request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
358+
},
359+
)
360+
const headers4 = response4?.headers() || {}
361+
expect(response4?.status()).toBe(200)
362+
expect(headers4?.['x-nextjs-cache']).toBeUndefined()
363+
if (!headers4['cache-status'].includes('"Netlify Edge"; hit')) {
364+
// if we missed CDN cache, we will see Next cache hit status
365+
// as we reuse cached response
366+
expect(headers4['cache-status']).toMatch(/"Next.js"; hit/m)
367+
}
368+
expect(headers4['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable')
369+
370+
// the page is cached
371+
const date4 = await page.getByTestId('date-now').textContent()
372+
expect(date4).not.toBe(date3)
373+
374+
// lets revalidate again, but now we will wait for expiration time to pass to test that we are not serving stale anymore
375+
const revalidate2 = await page.goto(
376+
new URL(revalidateApiPath, next16TagRevalidation.url).href,
377+
)
378+
expect(revalidate2?.status()).toBe(200)
379+
380+
// revalidation should allow stale to be served for 5 seconds, let's wait to test case after expiration
381+
await page.waitForTimeout(6000)
382+
383+
// now after the revalidation it should have a different date
384+
const response5 = await pollUntilHeadersMatch(
385+
new URL(pagePath, next16TagRevalidation.url).href,
386+
{
387+
headersToMatch: {
388+
// revalidatePath just marks the page(s) as invalid and does NOT
389+
// automatically refreshes the cache. This request will cause
390+
// Next.js cache miss and new response will be generated and cached
391+
// Depending if we hit same CDN node as previous request, we might
392+
// get either fwd=miss or fwd=stale
393+
'cache-status': [/"Next.js"; fwd=miss/m, /"Netlify Edge"; fwd=(miss|stale)/m],
394+
},
395+
headersNotMatchedMessage:
396+
'Third request to tested page should be a miss or stale on the Edge and miss in Next.js after on-demand revalidation',
397+
},
398+
)
399+
const headers5 = response5?.headers() || {}
400+
expect(response5?.status()).toBe(200)
401+
expect(headers5?.['x-nextjs-cache']).toBeUndefined()
402+
expect(headers5['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable')
403+
404+
// the page has now an updated date
405+
const date5 = await page.getByTestId('date-now').textContent()
406+
expect(date5).not.toBe(date4)
407+
})
408+
}
409+
410+
test('updateTag in server action', async ({
411+
page,
412+
pollUntilHeadersMatch,
413+
next16TagRevalidation,
414+
}) => {
415+
const pagePath = `/update-tag/${pagePathSuffix}`
416+
// in case there is retry or some other test did hit that path before
417+
// we want to make sure that cdn cache is not warmed up
418+
const purgeCdnCache = await page.goto(
419+
new URL(`/api/purge-cdn?path=${pagePath}`, next16TagRevalidation.url).href,
420+
)
421+
expect(purgeCdnCache?.status()).toBe(200)
422+
423+
// wait a bit until cdn cache purge propagates
424+
await page.waitForTimeout(500)
425+
426+
const response1 = await pollUntilHeadersMatch(
427+
new URL(pagePath, next16TagRevalidation.url).href,
428+
{
429+
headersToMatch: {
430+
// either first time hitting this route or we invalidated
431+
// just CDN node in earlier step
432+
// we will invoke function and see Next cache hit status
433+
// in the response if it was prerendered at build time
434+
// or regenerated in previous attempt to run this test
435+
'cache-status': [
436+
/"Netlify Edge"; fwd=(miss|stale)/m,
437+
prerendered ? /"Next.js"; hit/m : /"Next.js"; (hit|fwd=miss)/m,
438+
],
439+
},
440+
headersNotMatchedMessage:
441+
'First request to tested page should be a miss or stale on the Edge and hit in Next.js',
442+
},
443+
)
444+
const headers1 = response1?.headers() || {}
445+
expect(response1?.status()).toBe(200)
446+
expect(headers1['x-nextjs-cache']).toBeUndefined()
447+
expect(headers1['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable')
448+
449+
const date1 = await page.getByTestId('date-now').textContent()
450+
451+
const h1 = await page.locator('h1').textContent()
452+
expect(h1).toBe(expectedH1Content)
453+
454+
const response2 = await pollUntilHeadersMatch(
455+
new URL(pagePath, next16TagRevalidation.url).href,
456+
{
457+
headersToMatch: {
458+
// we are hitting the same page again and we most likely will see
459+
// CDN hit (in this case Next reported cache status is omitted
460+
// as it didn't actually take place in handling this request)
461+
// or we will see CDN miss because different CDN node handled request
462+
'cache-status': /"Netlify Edge"; (hit|fwd=miss|fwd=stale)/m,
463+
},
464+
headersNotMatchedMessage:
465+
'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)',
466+
},
467+
)
468+
const headers2 = response2?.headers() || {}
469+
expect(response2?.status()).toBe(200)
470+
expect(headers2['x-nextjs-cache']).toBeUndefined()
471+
if (!headers2['cache-status'].includes('"Netlify Edge"; hit')) {
472+
// if we missed CDN cache, we will see Next cache hit status
473+
// as we reuse cached response
474+
expect(headers2['cache-status']).toMatch(/"Next.js"; hit/m)
475+
}
476+
expect(headers2['debug-netlify-cdn-cache-control']).toBe('s-maxage=31536000, durable')
477+
478+
// the page is cached
479+
const date2 = await page.getByTestId('date-now').textContent()
480+
expect(date2).toBe(date1)
481+
482+
await page.getByTestId('update-tag-button').click()
483+
484+
await expect(page.getByTestId('date-now')).not.toHaveText(date2!, { timeout: 15_000 })
485+
})
486+
})
487+
}
488+
})
489+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { purgeCache } from '@netlify/functions'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
4+
export async function GET(request: NextRequest) {
5+
const url = new URL(request.url)
6+
const pathToPurge = url.searchParams.get('path')
7+
8+
if (!pathToPurge) {
9+
return NextResponse.json(
10+
{
11+
status: 'error',
12+
error: 'missing "path" query parameter',
13+
},
14+
{ status: 400 },
15+
)
16+
}
17+
try {
18+
await purgeCache({ tags: [`_N_T_${encodeURI(pathToPurge)}`] })
19+
return NextResponse.json(
20+
{
21+
status: 'ok',
22+
},
23+
{
24+
status: 200,
25+
},
26+
)
27+
} catch (error) {
28+
return NextResponse.json(
29+
{
30+
status: 'error',
31+
error: error.toString(),
32+
},
33+
{
34+
status: 500,
35+
},
36+
)
37+
}
38+
}
39+
40+
export const dynamic = 'force-dynamic'
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { revalidateTag } from 'next/cache'
3+
4+
export async function GET(request: NextRequest) {
5+
const url = new URL(request.url)
6+
const tagToRevalidate = url.searchParams.get('tag') ?? 'collection'
7+
8+
let profile: Parameters<typeof revalidateTag>[1] | undefined | null
9+
if (url.searchParams.has('profile')) {
10+
profile = url.searchParams.get('profile')
11+
} else if (url.searchParams.has('expire')) {
12+
profile = {
13+
expire: parseInt(url.searchParams.get('expire')),
14+
}
15+
}
16+
17+
if (profile) {
18+
console.log(`Revalidating tag: ${tagToRevalidate}, profile: ${JSON.stringify(profile)}`)
19+
20+
revalidateTag(tagToRevalidate, profile)
21+
return NextResponse.json({ revalidated: true, now: new Date().toISOString() })
22+
} else {
23+
return NextResponse.json({ error: 'Missing profile or expire query param' }, { status: 400 })
24+
}
25+
}
26+
27+
export const dynamic = 'force-dynamic'
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const metadata = {
2+
title: 'Revalidate fetch',
3+
description: 'Description for Simple Next App',
4+
}
5+
6+
export default function RootLayout({ children }) {
7+
return (
8+
<html lang="en">
9+
<body>{children}</body>
10+
</html>
11+
)
12+
}

0 commit comments

Comments
 (0)