Files
neuf-www/web/tests/smoke/smoke.test.ts
T

360 lines
13 KiB
TypeScript

// 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<MockState>): Promise<void> {
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<string, number>;
// 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 <head> 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("</head>")[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 <head>", async (path, title) => {
const html = await (await get(path)).text();
expect(head(html)).toMatch(new RegExp(`<title>[^<]*${title}[^<]*</title>`));
expect(head(html)).toContain('property="og:title"');
// nothing left to be moved into place by a script
expect(html.split("</head>")[1]).not.toContain("<title>");
});
});
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");
});
});