/** * E2E integration test — SPA static serving (Phase P5 cutover, #1444; tests * added in P6, #1445, review follow-up SF1 on PR #1453). * * Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the * controllers) against a fixture dist directory, and pins the serving * contract: * * 1. `/` and client-side deep links fall back to index.html. * 2. Declared API routes win over the catch-all. * 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the * SPA page — including with a query string (`/api?x=1`). * 4. Static files are served exactly; hashed /assets/ files get immutable * cache headers, everything else revalidates (max-age=0), and a missing * /assets/ file is a 404 — never the SPA fallback. * 5. WEB_DIST_DIR unset disables SPA serving entirely. * 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot. */ import 'reflect-metadata'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import { Test } from '@nestjs/testing'; import { Controller, Get, type INestApplication } from '@nestjs/common'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import request from 'supertest'; import { mountSpaStatic } from './serve-spa.js'; const INDEX_HTML = 'mosaic spa fixture\n'; const ASSET_JS = 'console.log("hashed asset");\n'; @Controller('api/spa-test') class SpaTestController { @Get('ping') ping(): { ok: boolean } { return { ok: true }; } } async function createApp(): Promise { const moduleRef = await Test.createTestingModule({ controllers: [SpaTestController], }).compile(); const app = moduleRef.createNestApplication(new FastifyAdapter()); await app.init(); // Mirror main.ts ordering: SPA mounting happens after the app (and its // controllers) exist, before listen. await mountSpaStatic(app as NestFastifyApplication); await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready(); return app; } describe('SPA static serving — fixture dist dir', () => { let app: INestApplication; let distDir: string; let previousWebDistDir: string | undefined; beforeAll(async () => { distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-')); await writeFile(path.join(distDir, 'index.html'), INDEX_HTML); await writeFile(path.join(distDir, 'favicon.svg'), '\n'); await mkdir(path.join(distDir, 'assets'), { recursive: true }); await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS); previousWebDistDir = process.env['WEB_DIST_DIR']; process.env['WEB_DIST_DIR'] = distDir; app = await createApp(); }); afterAll(async () => { if (previousWebDistDir === undefined) { delete process.env['WEB_DIST_DIR']; } else { process.env['WEB_DIST_DIR'] = previousWebDistDir; } await app.close(); await rm(distDir, { recursive: true, force: true }); }); it('serves index.html at /', async () => { const res = await request(app.getHttpServer()).get('/'); expect(res.status).toBe(200); expect(res.text).toBe(INDEX_HTML); expect(res.headers['content-type']).toContain('text/html'); }); it('falls back to index.html for client-side deep links', async () => { for (const deepLink of ['/chat', '/projects/42', '/settings']) { const res = await request(app.getHttpServer()).get(deepLink); expect(res.status, deepLink).toBe(200); expect(res.text, deepLink).toBe(INDEX_HTML); } }); it('declared API routes win over the SPA catch-all', async () => { const res = await request(app.getHttpServer()).get('/api/spa-test/ping'); expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); }); it('unknown backend paths are JSON 404s, never the SPA page', async () => { for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) { const res = await request(app.getHttpServer()).get(backendPath); expect(res.status, backendPath).toBe(404); expect(res.headers['content-type'], backendPath).toContain('application/json'); expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 }); } }); it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => { const res = await request(app.getHttpServer()).get('/api?x=1'); expect(res.status).toBe(404); expect(res.headers['content-type']).toContain('application/json'); }); it('serves static files exactly', async () => { const res = await request(app.getHttpServer()).get('/favicon.svg'); expect(res.status).toBe(200); // supertest buffers image/svg+xml as a Buffer body, not res.text. const body = res.text || (res.body as Buffer).toString('utf8'); expect(body).toBe('\n'); }); it('hashed /assets/ files get immutable cache headers', async () => { const res = await request(app.getHttpServer()).get('/assets/app-abc123.js'); expect(res.status).toBe(200); expect(res.text).toBe(ASSET_JS); expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable'); }); it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => { // The exact request a browser with a stale index.html makes after a // deploy: the old hashed filename. Serving index.html here would poison // caches with a year-long immutable entry whose body is HTML. for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) { const res = await request(app.getHttpServer()).get(missingAsset); expect(res.status, missingAsset).toBe(404); expect(res.text, missingAsset).not.toContain('mosaic spa fixture'); // The 404 carries no cache-control at all; ?? '' keeps the assertion valid. expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable'); } }); it('index.html and non-asset files revalidate (no immutable caching)', async () => { for (const revalidating of ['/', '/chat', '/favicon.svg']) { const res = await request(app.getHttpServer()).get(revalidating); expect(res.headers['cache-control'], revalidating).not.toContain('immutable'); } }); it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => { const res = await request(app.getHttpServer()).post('/chat'); expect(res.status).toBe(404); expect(res.text).not.toContain('mosaic spa fixture'); }); }); describe('SPA static serving — configuration edges', () => { it('WEB_DIST_DIR unset disables SPA serving', async () => { const previous = process.env['WEB_DIST_DIR']; delete process.env['WEB_DIST_DIR']; try { const app = await createApp(); const res = await request(app.getHttpServer()).get('/chat'); expect(res.status).toBe(404); await app.close(); } finally { if (previous !== undefined) { process.env['WEB_DIST_DIR'] = previous; } } }); it('WEB_DIST_DIR without index.html fails at boot', async () => { const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-')); const previous = process.env['WEB_DIST_DIR']; process.env['WEB_DIST_DIR'] = emptyDir; try { await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/); } finally { if (previous === undefined) { delete process.env['WEB_DIST_DIR']; } else { process.env['WEB_DIST_DIR'] = previous; } await rm(emptyDir, { recursive: true, force: true }); } }); });