From 7488a375e3f15999c7fdcf338c88dc53bba8b062 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 10:58:58 +0700 Subject: [PATCH] feat(locks): expose GET/POST /api/locks over named-lock.js (D) --- server/__tests__/locks-api.test.js | 127 +++++++++++++++++++++++++++++ server/index.js | 2 + server/routes/locks.js | 51 ++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 server/__tests__/locks-api.test.js create mode 100644 server/routes/locks.js diff --git a/server/__tests__/locks-api.test.js b/server/__tests__/locks-api.test.js new file mode 100644 index 0000000..3d2c8cf --- /dev/null +++ b/server/__tests__/locks-api.test.js @@ -0,0 +1,127 @@ +/** + * @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"); + }); +}); diff --git a/server/index.js b/server/index.js index 391b755..99a91b8 100644 --- a/server/index.js +++ b/server/index.js @@ -66,6 +66,7 @@ const webhooksRouter = require("./routes/webhooks"); const remoteSourcesRouter = require("./routes/remote-sources"); const metricsRouter = require("./routes/metrics"); const lanesRouter = require("./routes/lanes"); +const locksRouter = require("./routes/locks"); const APP_VERSION = (() => { try { @@ -101,6 +102,7 @@ function createApp() { app.use("/api/cc-config", ccConfigRouter); app.use("/api/run", runRouter); app.use("/api/lanes", lanesRouter); + app.use("/api/locks", locksRouter); app.use("/api/alerts", alertsRouter); app.use("/api/webhooks", webhooksRouter); app.use("/api/remote-sources", remoteSourcesRouter); diff --git a/server/routes/locks.js b/server/routes/locks.js new file mode 100644 index 0000000..43ba69c --- /dev/null +++ b/server/routes/locks.js @@ -0,0 +1,51 @@ +/** + * @file HTTP surface for cross-lane named locks (`server/lib/named-lock.js`). + * Every route is single-shot — this layer never blocks waiting for a lock to + * free up. The CLI (`ccam lock acquire`) owns the polling loop; CCAM's + * server-side primitives stay non-orchestrating, same as every other lane + * route in this project. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { Router } = require("express"); +const namedLock = require("../lib/named-lock"); +const { sameOriginGuard } = require("./run"); + +const router = Router(); + +router.get("/", (_req, res) => { + res.json({ locks: namedLock.listLocks() }); +}); + +router.post("/:name/acquire", sameOriginGuard, (req, res) => { + const holder = typeof req.body?.holder === "string" ? req.body.holder.trim() : ""; + if (!holder) { + return res.status(400).json({ error: { code: "EBADHOLDER", message: "holder is required" } }); + } + const result = namedLock.tryAcquire(req.params.name, holder); + if (result.acquired) return res.json(result); + res.status(409).json(result); +}); + +router.post("/:name/release", sameOriginGuard, (req, res) => { + const holder = typeof req.body?.holder === "string" ? req.body.holder.trim() : ""; + if (!holder) { + return res.status(400).json({ error: { code: "EBADHOLDER", message: "holder is required" } }); + } + try { + namedLock.release(req.params.name, holder); + res.json({ ok: true }); + } catch (err) { + if (err.code === "ENOLOCK") { + return res.status(404).json({ error: { code: err.code, message: err.message } }); + } + if (err.code === "ENOTHOLDER") { + return res.status(409).json({ + error: { code: err.code, message: err.message, currentHolder: err.currentHolder }, + }); + } + res.status(500).json({ error: { code: err.code, message: err.message } }); + } +}); + +module.exports = router;