refactor: extract static page routes into routes/pages.ts (TDD)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 18m9s

- 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
This commit is contained in:
DocFast CEO 2026-03-10 08:04:22 +01:00
parent 76b2179be9
commit 7ae20ea280
3 changed files with 161 additions and 81 deletions

View file

@ -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();
});
});

View file

@ -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

80
src/routes/pages.ts Normal file
View file

@ -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",
],
});
});