From 7ae20ea280172d860f3ab5df3ec19c4b8c7c3c6a Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Tue, 10 Mar 2026 08:04:22 +0100 Subject: [PATCH] refactor: extract static page routes into routes/pages.ts (TDD) - Created src/routes/pages.ts with pagesRouter consolidating all page-serving routes: /, /docs, /impressum, /privacy, /terms, /examples, /status, /favicon.ico, /openapi.json, /api - Reduced index.ts from 391 to 314 lines (20% reduction) - Removed unused imports (createRequire, APP_VERSION, swaggerSpec) from index.ts - 4 TDD tests verifying router exports and route definitions - 622 tests passing, 0 tsc errors --- src/__tests__/pages-router.test.ts | 77 +++++++++++++++++++++++++++ src/index.ts | 85 ++---------------------------- src/routes/pages.ts | 80 ++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 81 deletions(-) create mode 100644 src/__tests__/pages-router.test.ts create mode 100644 src/routes/pages.ts diff --git a/src/__tests__/pages-router.test.ts b/src/__tests__/pages-router.test.ts new file mode 100644 index 0000000..5f2b3c2 --- /dev/null +++ b/src/__tests__/pages-router.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock sendFile to track calls +const sendFileMock = vi.fn(); +const setHeaderMock = vi.fn().mockReturnThis(); + +vi.mock("express", async () => { + const actual = await vi.importActual("express"); + return actual; +}); + +describe("pages router", () => { + it("exports a pagesRouter express Router", async () => { + const { pagesRouter } = await import("../routes/pages.js"); + expect(pagesRouter).toBeDefined(); + expect(typeof pagesRouter).toBe("function"); // Express routers are functions + }); + + it("defines GET routes for all static pages", async () => { + const { pagesRouter } = await import("../routes/pages.js"); + // Express Router stores routes in router.stack + const routes = (pagesRouter as any).stack + .filter((layer: any) => layer.route) + .map((layer: any) => ({ + path: layer.route.path, + method: Object.keys(layer.route.methods)[0], + })); + + const expectedPages = [ + { path: "/", method: "get" }, + { path: "/docs", method: "get" }, + { path: "/impressum", method: "get" }, + { path: "/privacy", method: "get" }, + { path: "/terms", method: "get" }, + { path: "/examples", method: "get" }, + { path: "/status", method: "get" }, + { path: "/favicon.ico", method: "get" }, + ]; + + for (const expected of expectedPages) { + const found = routes.find( + (r: any) => r.path === expected.path && r.method === expected.method + ); + expect(found, `Missing route: GET ${expected.path}`).toBeDefined(); + } + }); + + it("defines GET /openapi.json route", async () => { + const { pagesRouter } = await import("../routes/pages.js"); + const routes = (pagesRouter as any).stack + .filter((layer: any) => layer.route) + .map((layer: any) => ({ + path: layer.route.path, + method: Object.keys(layer.route.methods)[0], + })); + + const found = routes.find( + (r: any) => r.path === "/openapi.json" && r.method === "get" + ); + expect(found, "Missing route: GET /openapi.json").toBeDefined(); + }); + + it("defines GET /api route", async () => { + const { pagesRouter } = await import("../routes/pages.js"); + const routes = (pagesRouter as any).stack + .filter((layer: any) => layer.route) + .map((layer: any) => ({ + path: layer.route.path, + method: Object.keys(layer.route.methods)[0], + })); + + const found = routes.find( + (r: any) => r.path === "/api" && r.method === "get" + ); + expect(found, "Missing route: GET /api").toBeDefined(); + }); +}); diff --git a/src/index.ts b/src/index.ts index e322131..151738e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,13 +2,9 @@ import express, { Request, Response } from "express"; import { randomUUID } from "crypto"; import { AuthenticatedRequest } from "./types.js"; import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot -import { createRequire } from "module"; import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; - -const _require = createRequire(import.meta.url); -const APP_VERSION: string = _require("../package.json").version; import path from "path"; import { fileURLToPath } from "url"; import rateLimit from "express-rate-limit"; @@ -25,9 +21,10 @@ import { pdfRateLimitMiddleware } from "./middleware/pdfRateLimit.js"; import { adminRouter } from "./routes/admin.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; import { loadKeys, getAllKeys } from "./services/keys.js"; +import { pagesRouter } from "./routes/pages.js"; import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; -import { swaggerSpec } from "./swagger.js"; + const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); @@ -163,31 +160,9 @@ app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, tem // Admin + usage routes (extracted to routes/admin.ts) app.use(adminRouter); -// Landing page +// Pages, favicon, docs, openapi.json, /api (extracted to routes/pages.ts) const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// Favicon route -app.get("/favicon.ico", (_req, res) => { - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'public, max-age=604800'); - res.sendFile(path.join(__dirname, "../public/favicon.svg")); -}); - -// Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup -app.get("/openapi.json", (_req, res) => { - res.json(swaggerSpec); -}); - -// Docs page (clean URL) -app.get("/docs", (_req, res) => { - // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. - // Override helmet's default CSP to allow 'unsafe-eval' + blob: for Swagger UI. - res.setHeader("Content-Security-Policy", - "default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'" - ); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/docs.html")); -}); +app.use(pagesRouter); // Static asset cache headers middleware app.use((req, res, next) => { @@ -197,63 +172,11 @@ app.use((req, res, next) => { next(); }); -// Landing page (explicit route to set Cache-Control header) -app.get("/", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=3600'); - res.sendFile(path.join(__dirname, "../public/index.html")); -}); - app.use(express.static(path.join(__dirname, "../public"), { etag: true, cacheControl: false, })); - -// Legal pages (clean URLs) -app.get("/impressum", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/impressum.html")); -}); - -app.get("/privacy", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/privacy.html")); -}); - -app.get("/terms", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/terms.html")); -}); - -app.get("/examples", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/examples.html")); -}); - -app.get("/status", (_req, res) => { - res.setHeader("Cache-Control", "public, max-age=60"); - res.sendFile(path.join(__dirname, "../public/status.html")); -}); - - -// API root -app.get("/api", (_req, res) => { - res.json({ - name: "DocFast API", - version: APP_VERSION, - endpoints: [ - "POST /v1/demo/html — Try HTML→PDF (no auth, watermarked, 5/hour)", - "POST /v1/demo/markdown — Try Markdown→PDF (no auth, watermarked, 5/hour)", - "POST /v1/convert/html — HTML→PDF (requires API key)", - "POST /v1/convert/markdown — Markdown→PDF (requires API key)", - "POST /v1/convert/url — URL→PDF (requires API key)", - "POST /v1/templates/:id/render", - "GET /v1/templates", - "POST /v1/billing/checkout — Start Pro subscription", - ], - }); -}); - // 404 handler - must be after all routes app.use((req, res) => { // Check if it's an API request diff --git a/src/routes/pages.ts b/src/routes/pages.ts new file mode 100644 index 0000000..2a77fed --- /dev/null +++ b/src/routes/pages.ts @@ -0,0 +1,80 @@ +import { Router, Request, Response } from "express"; +import path from "path"; +import { fileURLToPath } from "url"; +import { createRequire } from "module"; +import { swaggerSpec } from "../swagger.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const publicDir = path.join(__dirname, "../../public"); + +const _require = createRequire(import.meta.url); +const APP_VERSION: string = _require("../../package.json").version; + +export const pagesRouter = Router(); + +// Favicon +pagesRouter.get("/favicon.ico", (_req: Request, res: Response) => { + res.setHeader("Content-Type", "image/svg+xml"); + res.setHeader("Cache-Control", "public, max-age=604800"); + res.sendFile(path.join(publicDir, "favicon.svg")); +}); + +// OpenAPI spec +pagesRouter.get("/openapi.json", (_req: Request, res: Response) => { + res.json(swaggerSpec); +}); + +// Docs page — needs custom CSP for Swagger UI +pagesRouter.get("/docs", (_req: Request, res: Response) => { + res.setHeader( + "Content-Security-Policy", + "default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'" + ); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.sendFile(path.join(publicDir, "docs.html")); +}); + +// Landing page +pagesRouter.get("/", (_req: Request, res: Response) => { + res.setHeader("Cache-Control", "public, max-age=3600"); + res.sendFile(path.join(publicDir, "index.html")); +}); + +// Static pages with 24h cache +const staticPages: Array<{ route: string; file: string }> = [ + { route: "/impressum", file: "impressum.html" }, + { route: "/privacy", file: "privacy.html" }, + { route: "/terms", file: "terms.html" }, + { route: "/examples", file: "examples.html" }, +]; + +for (const { route, file } of staticPages) { + pagesRouter.get(route, (_req: Request, res: Response) => { + res.setHeader("Cache-Control", "public, max-age=86400"); + res.sendFile(path.join(publicDir, file)); + }); +} + +// Status page (shorter cache) +pagesRouter.get("/status", (_req: Request, res: Response) => { + res.setHeader("Cache-Control", "public, max-age=60"); + res.sendFile(path.join(publicDir, "status.html")); +}); + +// API root +pagesRouter.get("/api", (_req: Request, res: Response) => { + res.json({ + name: "DocFast API", + version: APP_VERSION, + endpoints: [ + "POST /v1/demo/html — Try HTML→PDF (no auth, watermarked, 5/hour)", + "POST /v1/demo/markdown — Try Markdown→PDF (no auth, watermarked, 5/hour)", + "POST /v1/convert/html — HTML→PDF (requires API key)", + "POST /v1/convert/markdown — Markdown→PDF (requires API key)", + "POST /v1/convert/url — URL→PDF (requires API key)", + "POST /v1/templates/:id/render", + "GET /v1/templates", + "POST /v1/billing/checkout — Start Pro subscription", + ], + }); +});