diff --git a/web/src/app/api/revalidate/route.test.ts b/web/src/app/api/revalidate/route.test.ts index e99ed1c..58f6130 100644 --- a/web/src/app/api/revalidate/route.test.ts +++ b/web/src/app/api/revalidate/route.test.ts @@ -121,6 +121,32 @@ describe("POST /api/revalidate", () => { expect(followUpCalls()).toHaveLength(1); }); + it("follows up each tag on its own", async () => { + setUp(); + + await POST(request({ "x-revalidate-secret": "hemmelig" })); + await vi.advanceTimersByTimeAsync(2_000); + await POST(request({ "x-revalidate-secret": "hemmelig" }, "?tag=today")); + await vi.advanceTimersByTimeAsync(10_000); + + expect(followUpCalls()).toEqual([ + "http://127.0.0.1:4321/api/revalidate?tag=cms", + "http://127.0.0.1:4321/api/revalidate?tag=today", + ]); + }); + + it("reports a follow-up that fails", async () => { + setUp(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + fetchMock.mockResolvedValue({ ok: false, status: 503 }); + + await POST(request({ "x-revalidate-secret": "hemmelig" })); + await vi.advanceTimersByTimeAsync(6_000); + + expect(error).toHaveBeenCalledTimes(1); + error.mockRestore(); + }); + it("is not scheduled by the server's own purges", async () => { setUp(); diff --git a/web/src/lib/cmsQueries.test.ts b/web/src/lib/cmsQueries.test.ts new file mode 100644 index 0000000..5abe754 --- /dev/null +++ b/web/src/lib/cmsQueries.test.ts @@ -0,0 +1,82 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import * as generated from "@/gql/graphql"; + +const SRC = fileURLToPath(new URL("..", import.meta.url)); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return entry.name === "gql" ? [] : sourceFiles(path); + return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) + ? [path] + : []; + }); +} + +const sources = sourceFiles(SRC).map((file) => ({ + file: file.slice(SRC.length), + text: readFileSync(file, "utf8"), +})); + +// const fooQuery = graphql(`query foo { ... }`) -> fooQuery: "foo" +const operationByConstant = new Map(); +for (const { text } of sources) { + const definition = /const (\w+) = graphql\(`\s*query (\w+)/g; + for (const [, constant, operation] of text.matchAll(definition)) { + operationByConstant.set(constant, operation); + } +} + +// The generated documents have their fragments inlined, so a field counts +// even when it is selected through a fragment +function selectsFutureEvents(operation: string): boolean { + const name = `${operation[0].toUpperCase()}${operation.slice(1)}Document`; + const document = (generated as Record)[name]; + if (!document) throw new Error(`no generated document called ${name}`); + return JSON.stringify(document).includes('"futureEvents"'); +} + +function callSites(fn: "cmsQuery" | "cmsQueryForToday") { + const call = new RegExp(`\\b${fn}\\(\\s*(\\w+)`, "g"); + return sources.flatMap(({ file, text }) => + [...text.matchAll(call)] + // the definitions themselves in app/client.ts + .filter(([, constant]) => constant !== "document") + .map(([, constant]) => { + const operation = operationByConstant.get(constant); + if (!operation) { + throw new Error(`${file}: can't find the query behind ${constant}`); + } + return { file, operation }; + }) + ); +} + +// The CMS decides by today's date what futureEvents contains. A query that +// selects it must also be purged at midnight, or it shows yesterday's events +describe("CMS queries", () => { + it("that select futureEvents go through cmsQueryForToday", () => { + const wrong = callSites("cmsQuery").filter(({ operation }) => + selectsFutureEvents(operation) + ); + expect(wrong).toEqual([]); + }); + + it("that don't depend on the date go through cmsQuery", () => { + const wrong = callSites("cmsQueryForToday").filter( + ({ operation }) => !selectsFutureEvents(operation) + ); + expect(wrong).toEqual([]); + }); + + it("are found by this scan", () => { + expect(callSites("cmsQuery").length).toBeGreaterThan(10); + expect(callSites("cmsQueryForToday").map((c) => c.operation).sort()).toEqual( + ["futureEvents", "futureEvents", "home"] + ); + }); +}); diff --git a/web/tests/smoke/smoke.test.ts b/web/tests/smoke/smoke.test.ts index 8122c5f..db43fdd 100644 --- a/web/tests/smoke/smoke.test.ts +++ b/web/tests/smoke/smoke.test.ts @@ -83,11 +83,54 @@ describe("pages", () => { ); }); +// 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) => - /data-active="true"[^>]*><span>Vis (liste|kalender)<\/span>/.exec(html)?.[1]; + [ + ...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");