carbon-tokenization / backend /tests /api-routes.test.ts
tfrere's picture
tfrere HF Staff
refactor(backend): modular server split with new routes, persistence and agent layer
f6678ab
Raw
History Blame Contribute Delete
5.24 kB
/**
* 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<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 {}
});
/**
* 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);
});
});