From 389b95ed85a4831bb009a3d965157917aa70f712 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Mon, 31 Aug 2026 03:26:54 +0200 Subject: [PATCH] web: smoke-test the built standalone app against a mock graphql cms --- .gitea/workflows/web.yaml | 2 + web/next.config.mjs | 6 +- web/package.json | 1 + web/tests/smoke/fixtures.ts | 441 ++++++++++++++++++++++++++++++++ web/tests/smoke/global-setup.ts | 113 ++++++++ web/tests/smoke/mock-cms.ts | 119 +++++++++ web/tests/smoke/smoke.test.ts | 166 ++++++++++++ web/vitest.smoke.config.mts | 23 ++ 8 files changed, 870 insertions(+), 1 deletion(-) create mode 100644 web/tests/smoke/fixtures.ts create mode 100644 web/tests/smoke/global-setup.ts create mode 100644 web/tests/smoke/mock-cms.ts create mode 100644 web/tests/smoke/smoke.test.ts create mode 100644 web/vitest.smoke.config.mts diff --git a/.gitea/workflows/web.yaml b/.gitea/workflows/web.yaml index db3c6b1..b43c21a 100644 --- a/.gitea/workflows/web.yaml +++ b/.gitea/workflows/web.yaml @@ -26,4 +26,6 @@ jobs: cache-dependency-path: web/package-lock.json - run: npm ci - run: npx tsc --noEmit + - run: npm run lint - run: npm test + - run: npm run test:smoke diff --git a/web/next.config.mjs b/web/next.config.mjs index 0f8cd10..4ede8cc 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -18,7 +18,11 @@ const nextConfig = { ], formats: ["image/avif", "image/webp"], minimumCacheTTL: 7 * 24 * 60 * 60, // 7 days - dangerouslyAllowLocalIP: process.env.NODE_ENV === "development", + // smoke tests build in production mode against a mock CMS on 127.0.0.1; + // the flag is serialized into the build, so it must be set at build time + dangerouslyAllowLocalIP: + process.env.NODE_ENV === "development" || + process.env.SMOKE_ALLOW_LOCAL_IMAGES === "1", }, turbopack: { root: __dirname, diff --git a/web/package.json b/web/package.json index d893efd..f1e8b1e 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "test:smoke": "vitest run --config vitest.smoke.config.mts", "codegen": "graphql-codegen", "perf:build": "next build", "perf:serve": "next start -p 3100", diff --git a/web/tests/smoke/fixtures.ts b/web/tests/smoke/fixtures.ts new file mode 100644 index 0000000..b44e3a8 --- /dev/null +++ b/web/tests/smoke/fixtures.ts @@ -0,0 +1,441 @@ +// Canned GraphQL responses for the smoke-test mock CMS. +// +// Every piece is typed with `satisfies` against the *fragment* types generated +// by codegen (fragment masking makes the query types too weak — their +// `$fragmentRefs` props are optional). When `npm run codegen` regenerates +// after a schema change, `tsc --noEmit` fails here, flagging stale fixtures. + +import type { + AllAssociationSlugsQuery, + AllAssociationsQuery, + AllGenericSlugsQuery, + AllVenueSlugsQuery, + AssociationBySlugQuery, + AssociationFragment, + AssociationIndexFragment, + ContactIndexFragment, + ContactsQuery, + EventBySlugQuery, + EventCategoryFragment, + EventFragment, + EventIndexFragment, + EventIndexMetadataQuery, + EventListItemFragment, + EventOrganizerFragment, + EventOverviewItemFragment, + FutureEventsQuery, + GenericFragment, + GenericPageByUrlQuery, + HomeFragment, + HomeQuery, + ImageFragment, + NewsBySlugQuery, + NewsFragment, + NewsIndexFragment, + NewsIndexMetadataQuery, + NewsListItemFragment, + NewsQuery, + OpeningHoursSetFragment, + OpeningHoursSetsQuery, + PreviewPageQuery, + SearchQuery, + SponsorsPageFragment, + SponsorsQuery, + StudioFragment, + StudioQuery, + VenueBySlugQuery, + VenueFragment, + VenueIndexFragment, + VenueIndexQuery, + VenueRentalIndexFragment, + VenueRentalIndexQuery, +} from "@/gql/graphql"; + +export type MockState = { + newsHeadlineVersion: "v1" | "v2"; +}; + +export const SLUGS = { + genericUrl: "praktisk", // served at /praktisk, wagtail urlPath /home/praktisk/ + association: "testforening", + venue: "storsalen", + news: "test-artikkel", + event: "testkonsert", +} as const; + +export function newsTitle(state: MockState): string { + return `Testartikkel (${state.newsHeadlineVersion})`; +} + +type Resolver = (vars: Record, state: MockState) => unknown; +export type Fixtures = Record; + +// Fragment masking types a fragment spread's slot as an opaque ref, but the +// wire format is simply the fragment's fields. Each piece above proves its +// shape with `satisfies `; this cast lets it slot into the masked +// position of the enclosing query/fragment type. +function mask(value: T): { " $fragmentRefs"?: undefined } { + return value as never; +} + +export function makeFixtures(cmsBaseUrl: string): Fixtures { + const inAWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + const occurrenceStart = inAWeek.toISOString(); + const occurrenceEnd = new Date( + inAWeek.getTime() + 2 * 60 * 60 * 1000 + ).toISOString(); + + const image = { + id: "1", + url: `${cmsBaseUrl}/media/pig.png`, + width: 64, + height: 64, + alt: "En gris", + attribution: null, + } satisfies ImageFragment; + + const eventCategory = { + __typename: "EventCategory", + name: "Konsert", + slug: "konsert", + pig: "PIG_PINK", + showInFilters: true, + } satisfies EventCategoryFragment; + + const eventOrganizer = { + __typename: "EventOrganizer", + id: "1", + name: "Testforeningen", + slug: "testforeningen", + externalUrl: null, + association: null, + } satisfies EventOrganizerFragment; + + const eventListItem = { + __typename: "EventPage", + id: "10", + slug: SLUGS.event, + title: "Testkonsert", + subtitle: "En kveld med smoke tests", + featuredImage: mask(image), + occurrences: [ + { + __typename: "EventOccurrence", + id: "100", + start: occurrenceStart, + end: occurrenceEnd, + }, + ], + } satisfies EventListItemFragment; + + const venuePageRef = { + __typename: "VenuePage" as const, + id: "30", + slug: SLUGS.venue, + title: "Storsalen", + preposition: "i", + url: `/lokaler/${SLUGS.venue}/`, + }; + + const eventOverviewItem = { + ...eventListItem, + categories: [mask(eventCategory)], + occurrences: [ + { + __typename: "EventOccurrence", + id: "100", + start: occurrenceStart, + end: occurrenceEnd, + venueCustom: null, + venue: venuePageRef, + }, + ], + organizers: [mask(eventOrganizer)], + } satisfies EventOverviewItemFragment; + + const eventPage = { + ...eventOverviewItem, + seoTitle: "Testkonsert", + searchDescription: null, + lead: "Bli med på testkonsert.", + pig: "PIG_PINK", + facebookUrl: null, + ticketUrl: null, + free: true, + priceRegular: null, + priceMember: null, + priceStudent: null, + body: [], + } satisfies EventFragment; + + const eventIndex = { + __typename: "EventIndex", + id: "3", + slug: "arrangementer", + seoTitle: "Arrangementer", + searchDescription: null, + title: "Arrangementer", + } satisfies EventIndexFragment; + + const newsListItem = (state: MockState) => + ({ + __typename: "NewsPage", + id: "20", + slug: SLUGS.news, + title: newsTitle(state), + firstPublishedAt: "2026-08-01T12:00:00+02:00", + excerpt: "Et utdrag.", + featuredImage: mask(image), + }) satisfies NewsListItemFragment; + + const newsPage = (state: MockState) => + ({ + ...newsListItem(state), + seoTitle: newsTitle(state), + searchDescription: null, + lead: "Ingressen.", + body: [], + }) satisfies NewsFragment; + + const newsIndex = { + __typename: "NewsIndex", + id: "2", + slug: "aktuelt", + seoTitle: "Aktuelt", + searchDescription: null, + title: "Aktuelt", + lead: null, + } satisfies NewsIndexFragment; + + const home = { + __typename: "HomePage", + featuredEvents: [], + } satisfies HomeFragment; + + const genericPage = { + __typename: "GenericPage", + id: "40", + urlPath: `/home/${SLUGS.genericUrl}/`, + seoTitle: "Praktisk info", + searchDescription: null, + title: "Praktisk info", + lead: "Alt du trenger å vite.", + pig: "PIG_PINK", + body: [], + } satisfies GenericFragment; + + const association = { + __typename: "AssociationPage", + id: "50", + slug: SLUGS.association, + title: "Testforeningen", + seoTitle: "Testforeningen", + searchDescription: null, + excerpt: "En forening for testing.", + lead: null, + associationType: "Kultur", + websiteUrl: null, + body: [], + logo: null, + } satisfies AssociationFragment; + + const associationIndex = { + __typename: "AssociationIndex", + title: "Foreninger", + seoTitle: "Foreninger", + searchDescription: null, + lead: null, + body: [], + } satisfies AssociationIndexFragment; + + const venue = { + __typename: "VenuePage", + id: "30", + slug: SLUGS.venue, + title: "Storsalen", + seoTitle: "Storsalen", + searchDescription: null, + showAsBookable: true, + showInOverview: true, + floor: "2", + preposition: "i", + usedFor: "Konserter", + techSpecsUrl: null, + capabilityAudio: null, + capabilityAudioVideo: null, + capabilityBar: null, + capabilityLighting: null, + capacityLegal: "450", + capacityStanding: "450", + capacitySitting: "200", + images: [], + body: [], + featuredImage: null, + } satisfies VenueFragment; + + const venueIndex = { + __typename: "VenueIndex", + title: "Lokaler", + seoTitle: "Lokaler", + searchDescription: null, + lead: null, + body: [], + } satisfies VenueIndexFragment; + + const venueRentalIndex = { + __typename: "VenueRentalIndex", + title: "Utleie", + seoTitle: "Utleie", + searchDescription: null, + lead: null, + body: [], + } satisfies VenueRentalIndexFragment; + + const contactIndex = { + __typename: "ContactIndex", + title: "Kontakt", + seoTitle: "Kontakt", + searchDescription: null, + lead: null, + body: [], + } satisfies ContactIndexFragment; + + const sponsorsPage = { + __typename: "SponsorsPage", + title: "Sponsorer", + seoTitle: "Sponsorer", + searchDescription: null, + lead: null, + body: [], + sponsors: [], + } satisfies SponsorsPageFragment; + + const studioPage = { + __typename: "StudioPage", + id: "60", + title: "Studio", + seoTitle: "Studio", + searchDescription: null, + lead: null, + pig: "PIG_PINK", + logo: null, + body: [], + } satisfies StudioFragment; + + const openingHoursSet = { + name: "Vanlige åpningstider", + effectiveFrom: "2026-01-01", + effectiveTo: null, + announcement: null, + items: [], + } satisfies OpeningHoursSetFragment; + + return { + // --- build time: generateStaticParams --- + allGenericSlugs: () => + ({ + pages: [{ id: "40", urlPath: `/home/${SLUGS.genericUrl}/` }], + }) satisfies AllGenericSlugsQuery, + allAssociationSlugs: () => + ({ + pages: [{ id: "50", slug: SLUGS.association }], + }) satisfies AllAssociationSlugsQuery, + allVenueSlugs: () => + ({ + pages: [{ id: "30", slug: SLUGS.venue }], + }) satisfies AllVenueSlugsQuery, + + // --- build time: static pages --- + home: (_vars, state) => + ({ + events: { futureEvents: [mask(eventListItem)] }, + home: mask(home), + news: [mask(newsListItem(state))], + }) satisfies HomeQuery, + news: (_vars, state) => + ({ + index: mask(newsIndex), + news: [mask(newsListItem(state))], + }) satisfies NewsQuery, + newsIndexMetadata: () => + ({ index: mask(newsIndex) }) satisfies NewsIndexMetadataQuery, + futureEvents: () => + ({ + index: mask(eventIndex), + events: { futureEvents: [mask(eventOverviewItem)] }, + eventCategories: [mask(eventCategory)], + eventOrganizers: [mask(eventOrganizer)], + venues: [ + { + id: "30", + title: "Storsalen", + slug: SLUGS.venue, + preposition: "i", + }, + ], + }) satisfies FutureEventsQuery, + eventIndexMetadata: () => + ({ index: mask(eventIndex) }) satisfies EventIndexMetadataQuery, + allAssociations: () => + ({ + index: mask(associationIndex), + associations: [mask(association)], + }) satisfies AllAssociationsQuery, + contacts: () => ({ index: mask(contactIndex) }) satisfies ContactsQuery, + venueIndex: () => + ({ index: mask(venueIndex), venues: [mask(venue)] }) satisfies VenueIndexQuery, + venueRentalIndex: () => + ({ + index: mask(venueRentalIndex), + venues: [mask(venue)], + }) satisfies VenueRentalIndexQuery, + sponsors: () => ({ page: mask(sponsorsPage) }) satisfies SponsorsQuery, + studio: () => ({ page: mask(studioPage) }) satisfies StudioQuery, + openingHoursSets: () => + ({ openingHoursSets: [mask(openingHoursSet)] }) satisfies OpeningHoursSetsQuery, + + // --- build time: prerendered dynamic pages (null for unknown slugs → notFound) --- + genericPageByUrl: (vars) => + ({ + page: + vars.urlPath === `/home/${SLUGS.genericUrl}/` ? mask(genericPage) : null, + }) satisfies GenericPageByUrlQuery, + associationBySlug: (vars) => + ({ + association: vars.slug === SLUGS.association ? mask(association) : null, + }) satisfies AssociationBySlugQuery, + venueBySlug: (vars) => + ({ + venue: vars.slug === SLUGS.venue ? mask(venue) : null, + }) satisfies VenueBySlugQuery, + + // --- runtime --- + newsBySlug: (vars, state) => + ({ + news: vars.slug === SLUGS.news ? mask(newsPage(state)) : null, + }) satisfies NewsBySlugQuery, + eventBySlug: (vars) => + ({ + event: vars.slug === SLUGS.event ? mask(eventPage) : null, + }) satisfies EventBySlugQuery, + search: (vars) => + ({ + results: + typeof vars.query === "string" && vars.query.length > 0 + ? [ + { + __typename: "GenericPage", + id: "40", + title: "Praktisk info", + url: `/${SLUGS.genericUrl}/`, + lead: "Alt du trenger å vite.", + }, + ] + : [], + }) satisfies SearchQuery, + previewPage: (_vars, state) => + ({ + page: { __typename: "NewsPage" as const, ...mask(newsPage(state)) }, + }) satisfies PreviewPageQuery, + }; +} diff --git a/web/tests/smoke/global-setup.ts b/web/tests/smoke/global-setup.ts new file mode 100644 index 0000000..5ba61db --- /dev/null +++ b/web/tests/smoke/global-setup.ts @@ -0,0 +1,113 @@ +// Builds the app against the mock CMS and serves the standalone output, +// reproducing the Dockerfile's layout (server.js + .next/static + public). + +import { spawn, type ChildProcess } from "node:child_process"; +import { cpSync, existsSync, rmSync } from "node:fs"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { TestProject } from "vitest/node"; + +import { startMockCms } from "./mock-cms"; + +const WEB = fileURLToPath(new URL("../..", import.meta.url)); + +declare module "vitest" { + interface ProvidedContext { + appUrl: string; + mockUrl: string; + } +} + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = http.createServer(); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address() as AddressInfo; + srv.close((err) => (err ? reject(err) : resolve(port))); + }); + }); +} + +async function waitForOk(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok) return; + lastError = new Error(`GET ${url} -> ${res.status}`); + } catch (err) { + lastError = err; + } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`timed out waiting for ${url}: ${lastError}`); +} + +export default async function setup(project: TestProject) { + const mock = await startMockCms(); + const appPort = await getFreePort(); + const appUrl = `http://127.0.0.1:${appPort}`; + + const env = { + ...process.env, + // process env beats web/.env, steering the build away from cms.neuf.no + WAGTAIL_BASE_URL: mock.url, + // production builds refuse local-IP image sources without this override + SMOKE_ALLOW_LOCAL_IMAGES: "1", + REVALIDATE_WEBHOOK_SECRET: "smoke-secret", + URL: appUrl, + NEXT_TELEMETRY_DISABLED: "1", + }; + + rmSync(join(WEB, ".next", "standalone"), { recursive: true, force: true }); + // async spawn: the mock CMS lives in this process, so the event loop must + // stay free to answer the build's GraphQL requests + await new Promise((resolve, reject) => { + const build = spawn("node", ["node_modules/next/dist/bin/next", "build"], { + cwd: WEB, + env, + stdio: "inherit", + }); + build.on("error", reject); + build.on("exit", (code) => + code === 0 + ? resolve() + : reject(new Error(`next build exited with code ${code}`)) + ); + }); + + if (mock.graphqlRequests === 0) { + throw new Error( + "next build made no GraphQL requests to the mock CMS — is WAGTAIL_BASE_URL being overridden?" + ); + } + + // mirror the Dockerfile: standalone lacks static assets and public/ + const standalone = join(WEB, ".next", "standalone"); + if (!existsSync(join(standalone, "server.js"))) { + throw new Error(`no server.js in ${standalone} — standalone layout changed?`); + } + cpSync(join(WEB, ".next", "static"), join(standalone, ".next", "static"), { + recursive: true, + }); + cpSync(join(WEB, "public"), join(standalone, "public"), { recursive: true }); + + const app: ChildProcess = spawn("node", ["server.js"], { + cwd: standalone, + env: { ...env, PORT: String(appPort), HOSTNAME: "127.0.0.1" }, + stdio: "inherit", + }); + + await waitForOk(`${appUrl}/`, 60_000); + + project.provide("appUrl", appUrl); + project.provide("mockUrl", mock.url); + + return async () => { + app.kill(); + await mock.close(); + }; +} diff --git a/web/tests/smoke/mock-cms.ts b/web/tests/smoke/mock-cms.ts new file mode 100644 index 0000000..bbdc370 --- /dev/null +++ b/web/tests/smoke/mock-cms.ts @@ -0,0 +1,119 @@ +// A tiny stand-in for the Wagtail CMS, reachable over HTTP so both +// `next build` and the standalone server (separate child processes) can +// query it. Serves the canned GraphQL responses from fixtures.ts, a 1x1 PNG +// for image URLs, and a state endpoint the revalidation test uses to +// "publish a change" in the CMS. + +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { makeFixtures, type Fixtures, type MockState } from "./fixtures"; + +// 1x1 transparent PNG +const TINY_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" +); + +export type MockCms = { + url: string; + state: MockState; + graphqlRequests: number; + close(): Promise; +}; + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +export async function startMockCms(): Promise { + const state: MockState = { newsHeadlineVersion: "v1" }; + let fixtures: Fixtures | null = null; // built once we know our own URL + + const mock: MockCms = { + url: "", + state, + graphqlRequests: 0, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ), + }; + + const server = http.createServer(async (req, res) => { + try { + if (req.method === "GET" && req.url?.startsWith("/media/")) { + res.writeHead(200, { + "content-type": "image/png", + "content-length": TINY_PNG.length, + }); + res.end(TINY_PNG); + return; + } + + if (req.method === "POST" && req.url === "/__mock/state") { + Object.assign(state, JSON.parse(await readBody(req))); + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + return; + } + + if (req.url?.startsWith("/api/graphql")) { + mock.graphqlRequests += 1; + // urql sends both POST (JSON body) and GET (URL params) requests + let body: { + query?: string; + operationName?: string; + variables?: Record; + }; + if (req.method === "GET") { + const params = new URL(req.url, mock.url).searchParams; + body = { + query: params.get("query") ?? undefined, + operationName: params.get("operationName") ?? undefined, + variables: JSON.parse(params.get("variables") ?? "{}"), + }; + } else { + body = JSON.parse(await readBody(req)); + } + const name = + body.operationName ?? + /(?:query|mutation)\s+(\w+)/.exec(body.query ?? "")?.[1]; + const resolve = name ? fixtures?.[name] : undefined; + res.writeHead(200, { "content-type": "application/json" }); + if (!resolve) { + // Loud gap detection: build-time queries throw on GraphQL errors, + // so a missing fixture fails `next build` with the name visible. + res.end( + JSON.stringify({ + errors: [{ message: `mock-cms: no fixture for operation ${name}` }], + }) + ); + return; + } + res.end(JSON.stringify({ data: resolve(body.variables ?? {}, state) })); + return; + } + + console.error(`mock-cms: unhandled ${req.method} ${req.url}`); + res.writeHead(404); + res.end(); + } catch (err) { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ errors: [{ message: String(err) }] })); + } + }); + + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()) + ); + const { port } = server.address() as AddressInfo; + mock.url = `http://127.0.0.1:${port}`; + fixtures = makeFixtures(mock.url); + return mock; +} diff --git a/web/tests/smoke/smoke.test.ts b/web/tests/smoke/smoke.test.ts new file mode 100644 index 0000000..ab62cd2 --- /dev/null +++ b/web/tests/smoke/smoke.test.ts @@ -0,0 +1,166 @@ +// 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 fetch-cache + tag revalidation round trip, and draft-mode +// preview. + +import { beforeAll, describe, expect, inject, test } from "vitest"; + +import { 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 setNewsVersion(version: "v1" | "v2"): Promise { + const res = await fetch(mock("/__mock/state"), { + method: "POST", + body: JSON.stringify({ newsHeadlineVersion: version }), + }); + expect(res.status).toBe(200); +} + +describe("pages", () => { + test.each([ + ["/", "Testkonsert"], + ["/aktuelt", "Testartikkel"], + ["/arrangementer", "Testkonsert"], + [`/${SLUGS.genericUrl}`, "Praktisk info"], + [`/foreninger/${SLUGS.association}`, "Testforeningen"], + [`/lokaler/${SLUGS.venue}`, "Storsalen"], + // not prerendered (empty generateStaticParams) — rendered on first request + [`/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"); + } + ); +}); + +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("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\//); + }); +}); + +describe("fetch cache and tag revalidation", () => { + const path = `/aktuelt/${SLUGS.news}`; + + beforeAll(async () => { + await setNewsVersion("v1"); + }); + + test("serves cached content until the webhook fires, then updates", async () => { + // prime the cache (the route is request-rendered, then cached) + 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)"); + }); +}); + +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"); + }); +}); diff --git a/web/vitest.smoke.config.mts b/web/vitest.smoke.config.mts new file mode 100644 index 0000000..a2952f6 --- /dev/null +++ b/web/vitest.smoke.config.mts @@ -0,0 +1,23 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +// Smoke tests: `next build` against a mock CMS, then HTTP assertions against +// the standalone server (the artifact the Docker image ships). Run with +// `npm run test:smoke`; kept out of `npm test` so unit tests stay fast. +export default defineConfig({ + test: { + include: ["tests/smoke/**/*.test.ts"], + globalSetup: ["tests/smoke/global-setup.ts"], + // the revalidation test mutates shared mock-CMS state + fileParallelism: false, + testTimeout: 30_000, + // globalSetup runs a full `next build` + hookTimeout: 600_000, + env: { TZ: "Europe/Oslo" }, + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +});