/** * @file Tests for GET/POST /api/locks — the HTTP surface over * server/lib/named-lock.js. Boots a real Express app + in-memory-ish SQLite * (temp file) like the other route test files in this suite. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const http = require("node:http"); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-locks-api-")); process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const { createApp } = require("../index"); const namedLock = require("../lib/named-lock"); let server; let PORT; before(async () => { const app = createApp(); server = http.createServer(app); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); PORT = server.address().port; }); after(async () => { await new Promise((resolve) => server.close(resolve)); fs.rmSync(SUITE_ROOT, { recursive: true, force: true }); }); function request(method, urlPath, body) { return new Promise((resolve, reject) => { const data = body ? JSON.stringify(body) : null; const req = http.request( { method, hostname: "127.0.0.1", port: PORT, path: urlPath, headers: { "Content-Type": "application/json", ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}), }, }, (res) => { let raw = ""; res.on("data", (chunk) => (raw += chunk)); res.on("end", () => { let json = null; try { json = JSON.parse(raw); } catch { /* empty body is fine for some responses */ } resolve({ status: res.statusCode, body: json }); }); } ); req.on("error", reject); if (data) req.write(data); req.end(); }); } describe("GET /api/locks", () => { it("lists currently-held locks", async () => { namedLock.tryAcquire("api-build", "lane1"); const res = await request("GET", "/api/locks"); assert.equal(res.status, 200); assert.ok(res.body.locks.some((l) => l.name === "api-build" && l.holder === "lane1")); namedLock.release("api-build", "lane1"); }); }); describe("POST /api/locks/:name/acquire", () => { it("acquires a free lock", async () => { const res = await request("POST", "/api/locks/api-acquire/acquire", { holder: "lane1" }); assert.equal(res.status, 200); assert.deepEqual(res.body, { acquired: true }); namedLock.release("api-acquire", "lane1"); }); it("409s with the current holder when already held", async () => { namedLock.tryAcquire("api-busy", "lane1"); const res = await request("POST", "/api/locks/api-busy/acquire", { holder: "lane2" }); assert.equal(res.status, 409); assert.equal(res.body.acquired, false); assert.equal(res.body.holder, "lane1"); namedLock.release("api-busy", "lane1"); }); it("400s when holder is missing", async () => { const res = await request("POST", "/api/locks/api-nobody/acquire", {}); assert.equal(res.status, 400); assert.equal(res.body.error.code, "EBADHOLDER"); }); }); describe("POST /api/locks/:name/release", () => { it("releases a held lock", async () => { namedLock.tryAcquire("api-release", "lane1"); const res = await request("POST", "/api/locks/api-release/release", { holder: "lane1" }); assert.equal(res.status, 200); assert.equal(res.body.ok, true); assert.equal(namedLock.status("api-release").held, false); }); it("409s when the holder doesn't match", async () => { namedLock.tryAcquire("api-release-2", "lane1"); const res = await request("POST", "/api/locks/api-release-2/release", { holder: "lane2" }); assert.equal(res.status, 409); assert.equal(res.body.error.code, "ENOTHOLDER"); namedLock.release("api-release-2", "lane1"); }); it("404s when the lock doesn't exist", async () => { const res = await request("POST", "/api/locks/never-held/release", { holder: "lane1" }); assert.equal(res.status, 404); assert.equal(res.body.error.code, "ENOLOCK"); }); });