| |
| |
| |
| |
| |
| |
| 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<typeof createApp>["app"]; |
| let httpServer: ReturnType<typeof createApp>["httpServer"]; |
| let hocuspocus: ReturnType<typeof createApp>["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 {} |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| 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."); |
|
|
| |
| |
| |
| 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"); |
|
|
| |
| |
| 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); |
| }); |
| }); |
|
|