feat(HAI-023): complete Step 2 — dashboard API upload/download/delete routes

This commit is contained in:
Dustin Byrne
2026-03-25 22:00:51 -04:00
parent 7f47f1e781
commit 507c0d6b67
4 changed files with 311 additions and 1 deletions

View File

@@ -13,8 +13,10 @@
},
"dependencies": {
"@hai/core": "workspace:*",
"@types/multer": "^2.1.0",
"express": "^5.1.0",
"lucide-react": "^1.7.0",
"multer": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import http from "node:http";
import { createApiRoutes } from "./routes.js";
import type { TaskStore } from "@hai/core";
import type { TaskStore, TaskAttachment } from "@hai/core";
import type { TaskDetail } from "@hai/core";
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
@@ -54,6 +54,49 @@ async function GET(app: express.Express, path: string): Promise<{ status: number
});
}
/** Helper: send a request with method/body and return { status, body } */
async function REQUEST(
app: express.Express,
method: string,
path: string,
body?: Buffer | string,
headers?: Record<string, string>,
): Promise<{ status: number; body: any }> {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const addr = server.address() as { port: number };
const url = new URL(`http://127.0.0.1:${addr.port}${path}`);
const req = http.request(
{ hostname: url.hostname, port: url.port, path: url.pathname, method, headers },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode!, body: data });
}
});
},
);
req.on("error", (err) => { server.close(); reject(err); });
if (body) req.write(body);
req.end();
});
});
}
/** Build a minimal multipart/form-data body */
function buildMultipart(fieldName: string, filename: string, contentType: string, content: Buffer): { body: Buffer; boundary: string } {
const boundary = "----TestBoundary" + Date.now();
const header = `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`;
const footer = `\r\n--${boundary}--\r\n`;
const body = Buffer.concat([Buffer.from(header), content, Buffer.from(footer)]);
return { body, boundary };
}
describe("GET /tasks/:id", () => {
let store: TaskStore;
@@ -99,3 +142,97 @@ describe("GET /tasks/:id", () => {
expect(res.body.error).toContain("Unexpected end of JSON input");
});
});
describe("Attachment routes", () => {
const FAKE_ATTACHMENT: TaskAttachment = {
filename: "1234-screenshot.png",
originalName: "screenshot.png",
mimeType: "image/png",
size: 100,
createdAt: "2026-01-01T00:00:00.000Z",
};
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
addAttachment: vi.fn().mockResolvedValue(FAKE_ATTACHMENT),
getAttachment: vi.fn(),
deleteAttachment: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, attachments: [] }),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("POST /tasks/:id/attachments — uploads a valid image", async () => {
const content = Buffer.from("fake png content");
const { body, boundary } = buildMultipart("file", "screenshot.png", "image/png", content);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
});
expect(res.status).toBe(201);
expect(res.body.filename).toBe("1234-screenshot.png");
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"HAI-001",
"screenshot.png",
expect.any(Buffer),
"image/png",
);
});
it("POST /tasks/:id/attachments — returns 400 for invalid mime type", async () => {
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Invalid mime type 'text/plain'. Allowed: image/png, image/jpeg, image/gif, image/webp"),
);
const content = Buffer.from("not an image");
const { body, boundary } = buildMultipart("file", "file.txt", "text/plain", content);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid mime type");
});
it("POST /tasks/:id/attachments — returns 400 for oversized file", async () => {
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("File too large"),
);
const content = Buffer.from("small but store rejects");
const { body, boundary } = buildMultipart("file", "big.png", "image/png", content);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("File too large");
});
it("DELETE /tasks/:id/attachments/:filename — deletes attachment", async () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/1234-screenshot.png");
expect(res.status).toBe(200);
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("HAI-001", "1234-screenshot.png");
});
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
const err: NodeJS.ErrnoException = new Error("Attachment not found");
err.code = "ENOENT";
(store.deleteAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/nope.png");
expect(res.status).toBe(404);
});
});

View File

@@ -1,8 +1,15 @@
import { Router } from "express";
import multer from "multer";
import { createReadStream } from "node:fs";
import type { TaskStore, Column, MergeResult } from "@hai/core";
import { COLUMNS } from "@hai/core";
import type { ServerOptions } from "./server.js";
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
@@ -101,6 +108,55 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Upload attachment
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
try {
if (!req.file) {
res.status(400).json({ error: "No file provided" });
return;
}
const attachment = await store.addAttachment(
req.params.id as string,
req.file.originalname,
req.file.buffer,
req.file.mimetype,
);
res.status(201).json(attachment);
} catch (err: any) {
const status = err.message.includes("Invalid mime type") || err.message.includes("File too large") ? 400 : 500;
res.status(status).json({ error: err.message });
}
});
// Download attachment
router.get("/tasks/:id/attachments/:filename", async (req, res) => {
try {
const { path, mimeType } = await store.getAttachment(req.params.id, req.params.filename);
res.setHeader("Content-Type", mimeType);
createReadStream(path).pipe(res);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: "Attachment not found" });
} else {
res.status(500).json({ error: err.message });
}
}
});
// Delete attachment
router.delete("/tasks/:id/attachments/:filename", async (req, res) => {
try {
const task = await store.deleteAttachment(req.params.id, req.params.filename);
res.json(task);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: "Attachment not found" });
} else {
res.status(500).json({ error: err.message });
}
}
});
// Get single task with prompt content
router.get("/tasks/:id", async (req, res) => {
try {