diff --git a/server/__tests__/lane-features-api.test.js b/server/__tests__/lane-features-api.test.js new file mode 100644 index 0000000..de70dec --- /dev/null +++ b/server/__tests__/lane-features-api.test.js @@ -0,0 +1,138 @@ +/** + * @file Tests for GET/POST /api/lanes/:id/features… — the HTTP surface over + * server/lib/lane-features.js. + * @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-features-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 lanesLib = require("../lib/lanes"); + +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 ok */ + } + resolve({ status: res.statusCode, body: json }); + }); + } + ); + req.on("error", reject); + if (data) req.write(data); + req.end(); + }); +} + +let laneSeq = 0; +function makeLane() { + laneSeq += 1; + const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fs.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); +} + +describe("POST /api/lanes/:id/features/activate", () => { + it("activates a new feature and echoes the canonicalized slug", async () => { + const lane = makeLane(); + const res = await request("POST", `/api/lanes/${lane.id}/features/activate`, { + slug: "feat/My Thing", + }); + assert.equal(res.status, 200); + assert.equal(res.body.feature.slug, "My-Thing"); + assert.equal(res.body.lane.active_feature_id, res.body.feature.id); + }); +}); + +describe("GET /api/lanes/:id/features", () => { + it("lists every feature with computed pipeline_nodes", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + const res = await request("GET", `/api/lanes/${lane.id}/features`); + assert.equal(res.status, 200); + assert.equal(res.body.features.length, 1); + assert.ok(Array.isArray(res.body.features[0].pipeline_nodes)); + }); +}); + +describe("GET /api/lanes/:id/features/:slug", () => { + it("returns one feature by slug", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + const res = await request("GET", `/api/lanes/${lane.id}/features/one`); + assert.equal(res.status, 200); + assert.equal(res.body.feature.slug, "one"); + }); + + it("404s ENOFEATURE for an unknown slug", async () => { + const lane = makeLane(); + const res = await request("GET", `/api/lanes/${lane.id}/features/never`); + assert.equal(res.status, 404); + assert.equal(res.body.error.code, "ENOFEATURE"); + }); + + it("shows an archived feature's final stage after the lane moves on", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + await request("POST", `/api/lanes/${lane.id}/stage`, { stage: "review" }); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "two" }); + + const res = await request("GET", `/api/lanes/${lane.id}/features/one`); + assert.equal(res.body.feature.stage, "review"); + assert.notEqual(res.body.feature.archived_at, null); + }); +}); + +describe("DELETE /api/lanes/:id cascades to its features", () => { + it("removes every lane_features row for a deleted lane", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + await request("DELETE", `/api/lanes/${lane.id}`); + const { db } = require("../db"); + const rows = db.prepare("SELECT * FROM lane_features WHERE lane_id = ?").all(lane.id); + assert.equal(rows.length, 0); + }); +}); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index f64c880..2501379 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -13,7 +13,8 @@ const fs = require("node:fs"); const path = require("node:path"); const { db } = require("../db"); const lanesLib = require("../lib/lanes"); -const { listPipelines } = require("../lib/pipelines"); +const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/pipelines"); +const laneFeatures = require("../lib/lane-features"); const { broadcast } = require("../websocket"); const runs = require("../lib/run-spawner"); const { sameOriginGuard } = require("./run"); @@ -64,6 +65,18 @@ function payload(lane) { return lanesLib.lanePayload(lane, lastEventAge(lane)); } +/** A feature row's pipeline view, computed the same way payload() computes + * it for a live lane — lets the client render an archived feature with the + * exact same PipelineMap component, no special-casing on the frontend. */ +function featurePayload(feature) { + const pipeline = getPipeline(feature.pipeline); + return { + ...feature, + pipeline_name: pipeline.name, + pipeline_nodes: nodeStates(pipeline, feature), + progress: progressPct(pipeline, feature), + }; +} /** Push the current state of one lane to every connected client. */ function broadcastLane(id) { const lane = lanesLib.getLane(id); @@ -240,6 +253,43 @@ router.get("/:id/preflight", async (req, res) => { * POST and Express matches on method as well as path. Verified by moving this * registration after it: the suite stayed green. */ +router.get("/:id/features", (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); + res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) }); +}); + +router.get("/:id/features/:slug", (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); + const feature = laneFeatures.getFeature(lane.id, req.params.slug); + if (!feature) { + return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } }); + } + res.json({ feature: featurePayload(feature) }); +}); + +router.post("/:id/features/activate", sameOriginGuard, (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); + const slug = typeof req.body?.slug === "string" ? req.body.slug : ""; + if (!slug) { + return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } }); + } + try { + const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, { + title: req.body?.title, + }); + broadcastLane(updated.id); + res.json({ lane: payload(updated), feature: featurePayload(feature) }); + } catch (err) { + if (err.code === "EBADSLUG") { + return res.status(400).json({ error: { code: err.code, message: err.message } }); + } + res.status(500).json({ error: { code: err.code, message: err.message } }); + } +}); + router.get("/:id/git", async (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });