web: test bot responses and date-dependent query caching
This commit is contained in:
3 files changed
+152
-1
No files matched your search
@@ -121,6 +121,32 @@ describe("POST /api/revalidate", () => {
|
|||||||
expect(followUpCalls()).toHaveLength(1);
|
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 () => {
|
it("is not scheduled by the server's own purges", async () => {
|
||||||
setUp();
|
setUp();
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
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<string, unknown>)[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"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <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", () => {
|
describe("event list", () => {
|
||||||
// The list is rendered per request inside the prerendered page, so that
|
// 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 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) =>
|
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 () => {
|
test("is server-rendered in list mode by default", async () => {
|
||||||
const { html } = await getPage("/arrangementer");
|
const { html } = await getPage("/arrangementer");
|
||||||
|
|||||||
Reference in new issue
Block a user