// HTTP smoke tests against the built standalone app (see global-setup.ts). // These cover the surfaces most likely to break on a Next.js upgrade: // prerendered and request-rendered routes, 404s, the events API, the image // optimizer, the cache + tag revalidation round trip, and draft-mode // preview. import { afterAll, beforeAll, describe, expect, inject, test } from "vitest"; import { type MockState, SLUGS } from "./fixtures"; const app = (path: string) => `${inject("appUrl")}${path}`; const mock = (path: string) => `${inject("mockUrl")}${path}`; async function getPage(path: string): Promise<{ status: number; html: string }> { const res = await fetch(app(path)); return { status: res.status, html: await res.text() }; } async function setMockState(state: Partial): Promise { const res = await fetch(mock("/__mock/state"), { method: "POST", body: JSON.stringify(state), }); expect(res.status).toBe(200); } const setNewsVersion = (version: "v1" | "v2") => setMockState({ newsHeadlineVersion: version }); type CmsRequests = Record; // GraphQL requests the mock CMS has received, per operation name async function cmsRequests(): Promise<{ total: CmsRequests; sinceBuild: CmsRequests; }> { const res = await fetch(mock("/__mock/requests")); return res.json() as Promise<{ total: CmsRequests; sinceBuild: CmsRequests }>; } // Nothing expires by time: the server purges its own cache when it starts // and at Oslo midnight (instrumentation.ts). // Must stay the first test: any later purge would also make this pass describe("startup purge", () => { test("pages from the build are rendered again", async () => { // the purge lands just after the server starts listening const deadline = Date.now() + 10_000; let sinceBuild: CmsRequests = {}; while (Date.now() < deadline) { await getPage("/"); ({ sinceBuild } = await cmsRequests()); if ((sinceBuild.home ?? 0) > 0) break; await new Promise((r) => setTimeout(r, 250)); } expect(sinceBuild.home ?? 0).toBeGreaterThan(0); }); }); describe("pages", () => { test.each([ ["/", "Testkonsert"], ["/aktuelt", "Testartikkel"], ["/arrangementer", "Testkonsert"], [`/${SLUGS.genericUrl}`, "Praktisk info"], [`/foreninger/${SLUGS.association}`, "Testforeningen"], [`/lokaler/${SLUGS.venue}`, "Storsalen"], // only the latest page of each is prerendered; these are the latest [`/aktuelt/${SLUGS.news}`, "Testartikkel"], [`/arrangementer/${SLUGS.event}`, "Testkonsert"], ])("%s renders and contains %s", async (path, needle) => { const { status, html } = await getPage(path); expect(status).toBe(200); expect(html).toContain(needle); }); test.each([["/aktuelt/finnes-ikke"], ["/tull/og-toys"]])( "%s is a 404", async (path) => { const { status, html } = await getPage(path); expect(status).toBe(404); expect(html).toContain("404"); } ); }); // next.config.mjs empties Next's list of "HTML-limited" bots, so crawlers // and link-preview bots get the cached pages. That is only right as long as // every page has its metadata in without running any script describe.each([ ["a link-preview bot", "facebookexternalhit/1.1"], ["Bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"], ])("%s", (_name, userAgent) => { const get = (path: string) => fetch(app(path), { headers: { "user-agent": userAgent } }); const head = (html: string) => html.split("")[0]; test("gets the cached page", async () => { await getPage("/kontakt"); const res = await get("/kontakt"); await res.text(); expect(res.headers.get("x-nextjs-cache")).toBe("HIT"); expect(res.headers.get("etag")).toBeTruthy(); expect(res.headers.get("cache-control")).toContain("s-maxage="); }); test.each([ ["/", "Chateau Neuf"], ["/kontakt", "Chateau Neuf"], ["/arrangementer", "Chateau Neuf"], [`/aktuelt/${SLUGS.news}`, "Testartikkel"], [`/arrangementer/${SLUGS.event}`, "Testkonsert"], [`/aktuelt/${SLUGS.newsOnDemand}`, "Testartikkel"], ])("finds the title and Open Graph tags of %s in ", async (path, title) => { const html = await (await get(path)).text(); expect(head(html)).toMatch(new RegExp(`[^<]*${title}[^<]*`)); expect(head(html)).toContain('property="og:title"'); // nothing left to be moved into place by a script expect(html.split("")[1]).not.toContain(""); }); }); describe("event list", () => { // The list is rendered per request inside the prerendered page, so that // the URL's filters are applied on the server (no layout shift) // the last match: the skeleton in the prerendered shell has the same // buttons, and the real list is streamed in after it const activeButton = (html: string) => [ ...html.matchAll(/data-active="true"[^>]*><span>Vis (liste|kalender)<\/span>/g), ].at(-1)?.[1]; test("is server-rendered in list mode by default", async () => { const { html } = await getPage("/arrangementer"); expect(activeButton(html)).toBe("liste"); }); test("is server-rendered with the mode from the URL", async () => { const { html } = await getPage("/arrangementer?mode=calendar"); expect(activeButton(html)).toBe("kalender"); }); }); describe("events api", () => { test("rejects a missing view parameter", async () => { const res = await fetch(app("/api/v1/events")); expect(res.status).toBe(400); }); test("returns compact events", async () => { const res = await fetch(app("/api/v1/events?view=compact-app")); expect(res.status).toBe(200); const body = (await res.json()) as { events: Array<{ id: string; slug: string; title: string; nextOccurrence: { start: string } | null; }>; total: number; }; expect(body.total).toBe(1); expect(body.events[0].slug).toBe(SLUGS.event); expect(body.events[0].nextOccurrence?.start).toBeTruthy(); }); }); describe("news api", () => { test("pages through the articles for \"load more\" on /aktuelt", async () => { const res = await fetch(app("/api/v1/news?offset=0&limit=5")); expect(res.status).toBe(200); const body = (await res.json()) as { news: Array<{ slug: string }>; total: number; }; expect(body.total).toBe(1); expect(body.news.map((n) => n.slug)).toEqual([SLUGS.news]); }); test("rejects a limit that is too large", async () => { const res = await fetch(app("/api/v1/news?limit=1000")); expect(res.status).toBe(400); }); }); describe("image optimizer", () => { test("optimizes a CMS-hosted image", async () => { const src = encodeURIComponent(mock("/media/pig.png")); const res = await fetch(app(`/_next/image?url=${src}&w=640&q=75`)); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toMatch(/^image\//); }); }); // Runs for a page prerendered at build time and for one rendered on its // first request, which is how most news and event pages are served describe.each([ ["prerendered", `/aktuelt/${SLUGS.news}`], ["rendered on demand", `/aktuelt/${SLUGS.newsOnDemand}`], ])("cache and tag revalidation (%s page)", (_kind, path) => { beforeAll(async () => { await setNewsVersion("v1"); }); test("serves cached content until the webhook fires, then updates", async () => { // prerendered at build time or rendered now; either way the CMS has v1 const first = await getPage(path); expect(first.status).toBe(200); expect(first.html).toContain("Testartikkel (v1)"); // the CMS now has v2, but the app must keep serving cached v1 await setNewsVersion("v2"); const cached = await getPage(path); expect(cached.html).toContain("Testartikkel (v1)"); // webhook auth: missing/wrong secret is rejected const noSecret = await fetch(app("/api/revalidate"), { method: "POST" }); expect(noSecret.status).toBe(401); const wrongSecret = await fetch(app("/api/revalidate"), { method: "POST", headers: { "x-revalidate-secret": "wrong" }, }); expect(wrongSecret.status).toBe(401); // correct secret purges the cms cache tag const revalidated = await fetch(app("/api/revalidate"), { method: "POST", headers: { "x-revalidate-secret": "smoke-secret" }, }); expect(revalidated.status).toBe(200); expect(await revalidated.json()).toMatchObject({ revalidated: true }); // the next render must see v2 (allow a few retries for the refresh) const deadline = Date.now() + 5_000; let html = ""; while (Date.now() < deadline) { ({ html } = await getPage(path)); if (html.includes("Testartikkel (v2)")) break; await new Promise((r) => setTimeout(r, 250)); } expect(html).toContain("Testartikkel (v2)"); }); test("prerendered pages pick up the change too", async () => { const { html } = await getPage("/"); expect(html).toContain("Testartikkel (v2)"); }); }); // Next stamps a cached page when its render finishes, so a page that was // being rendered while a purge came in looks newer than the purge. The // server sends itself a follow-up purge a few seconds after each webhook describe("purge during a render", () => { const path = `/aktuelt/${SLUGS.newsOnDemand}`; const webhook = () => fetch(app("/api/revalidate"), { method: "POST", headers: { "x-revalidate-secret": "smoke-secret" }, }); afterAll(async () => { await setMockState({ newsDelayMs: 0 }); }); test("two edits in quick succession end up showing the second", async () => { // first edit: published and purged, and the CMS is slow to answer await setMockState({ newsHeadlineVersion: "v1", newsDelayMs: 1_500 }); expect((await webhook()).status).toBe(200); const visitor = getPage(path); await new Promise((r) => setTimeout(r, 300)); // second edit lands while the visitor's page is still rendering await setMockState({ newsHeadlineVersion: "v2", newsDelayMs: 0 }); expect((await webhook()).status).toBe(200); // that visitor gets what the CMS had when it was asked expect((await visitor).html).toContain("Testartikkel (v1)"); // everyone after must get the second edit once the follow-up has run const deadline = Date.now() + 12_000; let html = ""; while (Date.now() < deadline) { ({ html } = await getPage(path)); if (html.includes("Testartikkel (v2)")) break; await new Promise((r) => setTimeout(r, 500)); } expect(html).toContain("Testartikkel (v2)"); }, 30_000); }); describe("midnight purge", () => { async function cacheStatus(path: string): Promise<string | null> { const res = await fetch(app(path)); await res.text(); return res.headers.get("x-nextjs-cache"); } test("the midnight purge re-renders pages but only refetches date-dependent data", async () => { const purgeToday = () => fetch(app("/api/revalidate?tag=today"), { method: "POST", headers: { "x-revalidate-secret": "smoke-secret" }, }); // settle: everything rendered and cached by this server process expect((await purgeToday()).status).toBe(200); await getPage("/"); await getPage("/kontakt"); expect(await cacheStatus("/")).toBe("HIT"); expect(await cacheStatus("/kontakt")).toBe("HIT"); const before = (await cmsRequests()).total; expect((await purgeToday()).status).toBe(200); // every page shows today's date somewhere, so every page renders again expect(await cacheStatus("/")).not.toBe("HIT"); expect(await cacheStatus("/kontakt")).not.toBe("HIT"); expect(await cacheStatus("/")).toBe("HIT"); expect(await cacheStatus("/kontakt")).toBe("HIT"); const after = (await cmsRequests()).total; // the home page asks the CMS for future events again... expect(after.home).toBe(before.home + 1); // ...while content that only changes on edit comes from the cache expect(after.contacts).toBe(before.contacts); expect(after.openingHoursSets).toBe(before.openingHoursSets); }); }); describe("preview", () => { test("draft mode round trip renders the preview page", async () => { const enable = await fetch( app("/api/preview?token=smoke-token&content_type=news.NewsPage"), { redirect: "manual" } ); expect([302, 307]).toContain(enable.status); expect(enable.headers.get("location")).toContain("/preview/render"); const setCookies = enable.headers.getSetCookie(); expect(setCookies.length).toBeGreaterThan(0); const cookieHeader = setCookies .map((c) => c.split(";")[0]) .join("; "); const render = await fetch(app("/preview/render"), { headers: { cookie: cookieHeader }, }); expect(render.status).toBe(200); expect(await render.text()).toContain("Testartikkel"); }); test("without the cookies the preview session is expired", async () => { const render = await fetch(app("/preview/render")); expect(render.status).toBe(200); expect(await render.text()).toContain("Preview session expired"); }); });