/** * @fileoverview `@fastify/static`'s `setHeaders` callback is what sets Cache-Control * on every asset Codeman serves, and v10 silently changed its contract. * * In v9 the first argument was a Node `ServerResponse`, so the body called * `res.setHeader(...)`. In v10 it is a `FastifyReply`, which has no `setHeader`, so * the v9 body throws `TypeError: res.setHeader is not a function` from inside * `@fastify/static` on EVERY static request. Nothing in the type-checker catches a * revert (the callback's parameter is inferred), and nothing else in the suite reads * these headers, so without this file the whole caching contract is untested. * * That contract is load-bearing: assets are served `immutable` for a year, and * `index.html` must revalidate every time or a deploy leaves browsers on stale * markup (see `cacheBustAssets` in server.ts). * * These tests drive a REAL WebServer on purpose. Asserting against an inline * re-registration of the plugin would keep passing after a revert in server.ts. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { WebServer } from '../src/web/server.js'; const TEST_PORT = 3183; /** * Fetch and fully drain the body. Both halves matter for teardown: an unconsumed * body leaves undici holding the socket, and a pooled keep-alive socket makes * `server.stop()` wait for it, which times out the afterAll hook. */ async function get(url: string): Promise { const res = await fetch(url, { headers: { connection: 'close' } }); await res.arrayBuffer(); return res; } describe('static asset Cache-Control headers', () => { let server: WebServer; let baseUrl: string; beforeAll(async () => { server = new WebServer(TEST_PORT, false, true); await server.start(); baseUrl = `http://localhost:${TEST_PORT}`; }); afterAll(async () => { await server.stop(); }, 60000); it('serves a static asset at all (proves setHeaders did not throw)', async () => { // The v9-form regression surfaces here first: @fastify/static invokes setHeaders // while streaming, so a TypeError inside it takes the response down rather than // merely omitting a header. const res = await get(`${baseUrl}/app.js`); expect(res.status).toBe(200); }); it('marks long-lived assets immutable', async () => { const res = await get(`${baseUrl}/app.js`); expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); }); it('makes static HTML revalidate so deploys are picked up', async () => { // ⚠️ upload.html, NOT index.html. `/index.html` has its own explicit route that // answers from renderIndexHtml() and never reaches @fastify/static, so asserting // on it passes even with setHeaders fully broken (verified: reverting server.ts // to the v9 form fails the two /app.js tests and leaves an index.html assertion // green). upload.html has no route of its own, so it is the only HTML that // actually exercises the `.html` branch of setHeaders. const res = await get(`${baseUrl}/upload.html`); expect(res.status).toBe(200); expect(res.headers.get('cache-control')).toBe('no-cache'); }); it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => { // Regression guard for the OTHER half of the v10 change. setHeaders runs for // sendFile() too, and v10 moved it from the raw response onto the reply, which // flipped precedence: the callback started overriding routes instead of being // overridden by them. That gave /sw.js `immutable, max-age=31536000` in place of // the `no-cache, no-store` its route sets — a service worker pinned for a year, // which is unrecoverable from the server side because clients stop asking. // Measured against v9 to confirm this is the historical behaviour, not a guess. const res = await get(`${baseUrl}/sw.js`); expect(res.status).toBe(200); expect(res.headers.get('cache-control')).toBe('no-cache, no-store'); }); it('serves the rendered index with no-cache too, via its own route', async () => { // Different code path from the above, same contract: index.html is generated per // request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would // pin browsers to the asset versions current at deploy time. const res = await get(`${baseUrl}/index.html`); expect(res.status).toBe(200); expect(res.headers.get('cache-control')).toContain('no-cache'); }); });