test: add integration tests for admin.ts and pages.ts routes
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m33s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m33s
- admin-integration.test.ts: 14 tests covering /v1/usage/me, /v1/usage, /v1/concurrency, /admin/cleanup, auth middleware (401/403/503) - pages-integration.test.ts: 10 tests covering favicon, openapi.json, docs, landing page, static pages, status, /api Both files now at 100% function/statement/branch/line coverage. All 696 tests pass.
This commit is contained in:
parent
fb68cf5546
commit
db35a0e521
2 changed files with 318 additions and 0 deletions
187
src/__tests__/admin-integration.test.ts
Normal file
187
src/__tests__/admin-integration.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
// Mock all heavy dependencies
|
||||
vi.mock("../services/browser.js", () => ({
|
||||
renderPdf: vi.fn(),
|
||||
renderUrlPdf: vi.fn(),
|
||||
initBrowser: vi.fn(),
|
||||
closeBrowser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/keys.js", () => ({
|
||||
loadKeys: vi.fn(),
|
||||
getAllKeys: vi.fn().mockReturnValue([]),
|
||||
isValidKey: vi.fn(),
|
||||
getKeyInfo: vi.fn(),
|
||||
isProKey: vi.fn(),
|
||||
keyStore: new Map(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/db.js", () => ({
|
||||
initDatabase: vi.fn(),
|
||||
pool: { query: vi.fn(), end: vi.fn() },
|
||||
queryWithRetry: vi.fn(),
|
||||
connectWithRetry: vi.fn(),
|
||||
cleanupStaleData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/verification.js", () => ({
|
||||
verifyToken: vi.fn(),
|
||||
loadVerifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../middleware/usage.js", () => ({
|
||||
usageMiddleware: (_req: any, _res: any, next: any) => next(),
|
||||
loadUsageData: vi.fn(),
|
||||
getUsageStats: vi.fn().mockReturnValue({ "test-key": { count: 42, month: "2026-03" } }),
|
||||
getUsageForKey: vi.fn().mockReturnValue({ count: 10, monthKey: "2026-03" }),
|
||||
flushDirtyEntries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../middleware/pdfRateLimit.js", () => ({
|
||||
pdfRateLimitMiddleware: (_req: any, _res: any, next: any) => next(),
|
||||
getConcurrencyStats: vi.fn().mockReturnValue({ active: 2, queued: 0, maxConcurrent: 10 }),
|
||||
}));
|
||||
|
||||
const TEST_KEY = "test-key-123";
|
||||
const ADMIN_KEY = "admin-key-456";
|
||||
|
||||
describe("Admin integration tests", () => {
|
||||
let app: express.Express;
|
||||
let originalAdminKey: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
originalAdminKey = process.env.ADMIN_API_KEY;
|
||||
process.env.ADMIN_API_KEY = ADMIN_KEY;
|
||||
|
||||
// Set up key mocks
|
||||
const keys = await import("../services/keys.js");
|
||||
vi.mocked(keys.isValidKey).mockImplementation((k: string) => k === TEST_KEY || k === ADMIN_KEY);
|
||||
vi.mocked(keys.getKeyInfo).mockImplementation((k: string) => {
|
||||
if (k === TEST_KEY) return { key: TEST_KEY, tier: "free" as const, email: "test@test.com", createdAt: "2026-01-01" };
|
||||
if (k === ADMIN_KEY) return { key: ADMIN_KEY, tier: "pro" as const, email: "admin@test.com", createdAt: "2026-01-01" };
|
||||
return undefined;
|
||||
});
|
||||
vi.mocked(keys.isProKey).mockImplementation((k: string) => k === ADMIN_KEY);
|
||||
|
||||
const { adminRouter } = await import("../routes/admin.js");
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(adminRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAdminKey !== undefined) {
|
||||
process.env.ADMIN_API_KEY = originalAdminKey;
|
||||
} else {
|
||||
delete process.env.ADMIN_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET /v1/usage/me", () => {
|
||||
it("returns 401 without auth", async () => {
|
||||
const res = await request(app).get("/v1/usage/me");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 with invalid key", async () => {
|
||||
const res = await request(app).get("/v1/usage/me").set("X-API-Key", "bad-key");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns usage stats with Bearer auth", async () => {
|
||||
const res = await request(app).get("/v1/usage/me").set("Authorization", `Bearer ${TEST_KEY}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
used: 10,
|
||||
limit: 100,
|
||||
plan: "demo",
|
||||
month: "2026-03",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns usage stats with X-API-Key auth", async () => {
|
||||
const res = await request(app).get("/v1/usage/me").set("X-API-Key", TEST_KEY);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan).toBe("demo");
|
||||
});
|
||||
|
||||
it("returns pro plan for pro key", async () => {
|
||||
const res = await request(app).get("/v1/usage/me").set("X-API-Key", ADMIN_KEY);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
plan: "pro",
|
||||
limit: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/usage (admin)", () => {
|
||||
it("returns 401 without auth", async () => {
|
||||
const res = await request(app).get("/v1/usage");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 with non-admin key", async () => {
|
||||
const res = await request(app).get("/v1/usage").set("X-API-Key", TEST_KEY);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Admin access required");
|
||||
});
|
||||
|
||||
it("returns usage stats with admin key", async () => {
|
||||
const res = await request(app).get("/v1/usage").set("X-API-Key", ADMIN_KEY);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty("test-key");
|
||||
});
|
||||
|
||||
it("returns 503 when ADMIN_API_KEY not set", async () => {
|
||||
delete process.env.ADMIN_API_KEY;
|
||||
const res = await request(app).get("/v1/usage").set("X-API-Key", ADMIN_KEY);
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe("Admin access not configured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/concurrency (admin)", () => {
|
||||
it("returns 403 with non-admin key", async () => {
|
||||
const res = await request(app).get("/v1/concurrency").set("X-API-Key", TEST_KEY);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns concurrency stats with admin key", async () => {
|
||||
const res = await request(app).get("/v1/concurrency").set("X-API-Key", ADMIN_KEY);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ active: 2, queued: 0, maxConcurrent: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /admin/cleanup (admin)", () => {
|
||||
it("returns 403 with non-admin key", async () => {
|
||||
const res = await request(app).post("/admin/cleanup").set("X-API-Key", TEST_KEY);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns cleanup results with admin key", async () => {
|
||||
const { cleanupStaleData } = await import("../services/db.js");
|
||||
vi.mocked(cleanupStaleData).mockResolvedValue({ deletedRows: 5 } as any);
|
||||
|
||||
const res = await request(app).post("/admin/cleanup").set("X-API-Key", ADMIN_KEY);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ status: "ok", cleaned: { deletedRows: 5 } });
|
||||
});
|
||||
|
||||
it("returns 500 when cleanup fails", async () => {
|
||||
const { cleanupStaleData } = await import("../services/db.js");
|
||||
vi.mocked(cleanupStaleData).mockRejectedValue(new Error("DB error"));
|
||||
|
||||
const res = await request(app).post("/admin/cleanup").set("X-API-Key", ADMIN_KEY);
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("Cleanup failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
131
src/__tests__/pages-integration.test.ts
Normal file
131
src/__tests__/pages-integration.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
// Mock heavy deps so we don't need DB
|
||||
vi.mock("../services/browser.js", () => ({
|
||||
renderPdf: vi.fn(),
|
||||
renderUrlPdf: vi.fn(),
|
||||
initBrowser: vi.fn(),
|
||||
closeBrowser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/keys.js", () => ({
|
||||
loadKeys: vi.fn(),
|
||||
getAllKeys: vi.fn().mockReturnValue([]),
|
||||
isValidKey: vi.fn().mockReturnValue(false),
|
||||
getKeyInfo: vi.fn(),
|
||||
isProKey: vi.fn(),
|
||||
keyStore: new Map(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/db.js", () => ({
|
||||
initDatabase: vi.fn(),
|
||||
pool: { query: vi.fn(), end: vi.fn() },
|
||||
queryWithRetry: vi.fn(),
|
||||
connectWithRetry: vi.fn(),
|
||||
cleanupStaleData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/verification.js", () => ({
|
||||
verifyToken: vi.fn(),
|
||||
loadVerifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../middleware/usage.js", () => ({
|
||||
usageMiddleware: (_req: any, _res: any, next: any) => next(),
|
||||
loadUsageData: vi.fn(),
|
||||
getUsageStats: vi.fn().mockReturnValue({}),
|
||||
getUsageForKey: vi.fn().mockReturnValue({ count: 0, monthKey: "2026-03" }),
|
||||
flushDirtyEntries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../middleware/pdfRateLimit.js", () => ({
|
||||
pdfRateLimitMiddleware: (_req: any, _res: any, next: any) => next(),
|
||||
getConcurrencyStats: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
describe("Pages integration tests", () => {
|
||||
let app: express.Express;
|
||||
|
||||
// Use a fresh import per suite to avoid cross-test pollution
|
||||
beforeAll(async () => {
|
||||
const { pagesRouter } = await import("../routes/pages.js");
|
||||
app = express();
|
||||
app.use(pagesRouter);
|
||||
});
|
||||
|
||||
describe("GET /favicon.ico", () => {
|
||||
it("returns SVG with correct Content-Type and Cache-Control", async () => {
|
||||
const res = await request(app).get("/favicon.ico");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toMatch(/image\/svg\+xml/);
|
||||
expect(res.headers["cache-control"]).toContain("public");
|
||||
expect(res.headers["cache-control"]).toContain("max-age=604800");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /openapi.json", () => {
|
||||
it("returns valid JSON with paths", async () => {
|
||||
const res = await request(app).get("/openapi.json");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toMatch(/json/);
|
||||
expect(res.body.paths).toBeDefined();
|
||||
expect(typeof res.body.paths).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /docs", () => {
|
||||
it("returns HTML with CSP header", async () => {
|
||||
const res = await request(app).get("/docs");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toMatch(/html/);
|
||||
expect(res.headers["content-security-policy"]).toBeDefined();
|
||||
expect(res.headers["content-security-policy"]).toContain("unsafe-eval");
|
||||
expect(res.headers["cache-control"]).toContain("max-age=86400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("returns HTML with Cache-Control", async () => {
|
||||
const res = await request(app).get("/");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toMatch(/html/);
|
||||
expect(res.headers["cache-control"]).toContain("public");
|
||||
expect(res.headers["cache-control"]).toContain("max-age=3600");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Static pages", () => {
|
||||
const pages = ["/impressum", "/privacy", "/terms", "/examples"];
|
||||
|
||||
for (const page of pages) {
|
||||
it(`GET ${page} returns 200 with 24h cache`, async () => {
|
||||
const res = await request(app).get(page);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["cache-control"]).toContain("public");
|
||||
expect(res.headers["cache-control"]).toContain("max-age=86400");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET /status", () => {
|
||||
it("returns 200 with short cache", async () => {
|
||||
const res = await request(app).get("/status");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["cache-control"]).toContain("max-age=60");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api", () => {
|
||||
it("returns JSON with version and endpoints", async () => {
|
||||
const res = await request(app).get("/api");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toMatch(/json/);
|
||||
expect(res.body.name).toBe("DocFast API");
|
||||
expect(typeof res.body.version).toBe("string");
|
||||
expect(Array.isArray(res.body.endpoints)).toBe(true);
|
||||
expect(res.body.endpoints.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue