/** * API Routes Tests (Section 3 of TESTS.md) * * Integration tests using Supertest against createApp(). * Runs with OAuth disabled (no SPACE_ID) so all routes are accessible. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import request from "supertest"; import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs"; import { mkdirSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import * as Y from "yjs"; import { setDataDir, docPath } from "../src/utils.js"; import { createApp } from "../src/create-app.js"; import { resetSaveTimers } from "../src/create-app.js"; let tmpDir: string; let app: ReturnType["app"]; let httpServer: ReturnType["httpServer"]; let hocuspocus: ReturnType["hocuspocus"]; function seedDoc(name: string, text: string) { const ydoc = new Y.Doc(); const fragment = ydoc.getXmlFragment("default"); const el = new Y.XmlElement("paragraph"); el.insert(0, [new Y.XmlText(text)]); fragment.insert(0, [el]); const frontmatter = ydoc.getMap("frontmatter"); frontmatter.set("title", "Test Article"); frontmatter.set("description", "A test article"); const state = Y.encodeStateAsUpdate(ydoc); writeFileSync(docPath(name), Buffer.from(state)); } beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "collab-api-test-")); mkdirSync(tmpDir, { recursive: true }); setDataDir(tmpDir); const result = createApp(); app = result.app; httpServer = result.httpServer; hocuspocus = result.hocuspocus; }); afterEach(async () => { resetSaveTimers(); try { await hocuspocus.destroy(); } catch {} try { httpServer.close(); } catch {} setDataDir(undefined); try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} }); /** * Drive the async publish pipeline end-to-end: POST returns a jobId, then we * consume /api/publish/stream until the "done" SSE event fires. Supertest * buffers the full response body once the server closes the stream, which * happens right after `done`. */ async function runPublishAndWait(): Promise<{ success: boolean; result?: { htmlUrl: string | null; pdfUrl: string | null; thumbUrl: string | null; success: boolean }; error?: string; }> { const post = await request(app).post("/api/publish").expect(200); expect(post.body).toHaveProperty("jobId"); const jobId: string = post.body.jobId; const stream = await request(app) .get(`/api/publish/stream?jobId=${encodeURIComponent(jobId)}`) .buffer(true) .parse((res, cb) => { let data = ""; res.setEncoding("utf8"); res.on("data", (chunk: string) => { data += chunk; }); res.on("end", () => cb(null, data)); }); const body = stream.text ?? (stream.body as unknown as string); const match = /event: done\ndata: (.+)/.exec(body); if (!match) throw new Error(`No done event in SSE body: ${body.slice(0, 500)}`); return JSON.parse(match[1]); } describe("3.1 Publish routes", () => { it("3.1.1 POST /api/publish returns a jobId and the SSE stream reports success", async () => { seedDoc("default", "Hello from the test article."); const done = await runPublishAndWait(); expect(done.success).toBe(true); expect(done.result?.success).toBe(true); expect(typeof done.result?.htmlUrl).toBe("string"); }, 60_000); it("3.1.2 Publish writes local HTML file", async () => { seedDoc("default", "Published content goes here."); await runPublishAndWait(); const publishedPath = join(tmpDir, "published", "default", "index.html"); expect(existsSync(publishedPath)).toBe(true); }, 60_000); it("3.1.3 Concurrent POST /api/publish returns 409 for the second request", async () => { seedDoc("default", "Concurrency check."); // Fire both in the same tick. The mutex is set synchronously inside the // first handler before `res.json({ jobId })` is returned, so the second // handler (scheduled right after) must see it and respond with 409. const [a, b] = await Promise.all([ request(app).post("/api/publish"), request(app).post("/api/publish"), ]); const statuses = [a.status, b.status].sort((x, y) => x - y); expect(statuses).toEqual([200, 409]); const winner = a.status === 200 ? a : b; expect(winner.body).toHaveProperty("jobId"); const loser = a.status === 409 ? a : b; expect(loser.body).toHaveProperty("error"); // Drain the SSE stream so the job releases cleanly and the test harness // doesn't leak open sockets. await request(app) .get(`/api/publish/stream?jobId=${encodeURIComponent(winner.body.jobId)}`) .buffer(true) .parse((res, cb) => { let data = ""; res.setEncoding("utf8"); res.on("data", (chunk: string) => { data += chunk; }); res.on("end", () => cb(null, data)); }); }, 60_000); }); describe("3.2 Auth status", () => { it("returns authenticated=true when OAuth is disabled", async () => { const res = await request(app) .get("/api/auth/status") .expect(200); expect(res.body).toHaveProperty("authenticated", true); expect(res.body).toHaveProperty("canEdit", true); }); });