feat(21-01): REST API routes and route tests for chat conversations

- Add chatRoutes factory with 11 endpoints (conversations + messages)
- Mount chatRoutes in app.ts after activityRoutes
- Export chatRoutes from routes/index.ts
- Add chat-routes test suite (12 tests, all passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Mikkel Georgsen 2026-04-01 13:02:52 +02:00
parent 8e16cec7a9
commit 5c969bb9da
4 changed files with 323 additions and 0 deletions

View file

@ -0,0 +1,219 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { chatRoutes } from "../routes/chat.js";
const mockChatService = vi.hoisted(() => ({
listConversations: vi.fn(),
createConversation: vi.fn(),
getConversation: vi.fn(),
updateConversation: vi.fn(),
softDeleteConversation: vi.fn(),
archiveConversation: vi.fn(),
unarchiveConversation: vi.fn(),
pinConversation: vi.fn(),
unpinConversation: vi.fn(),
listMessages: vi.fn(),
addMessage: vi.fn(),
}));
vi.mock("../services/chat.js", () => ({
chatService: () => mockChatService,
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
next();
});
app.use("/api", chatRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("chat routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/companies/:companyId/conversations", () => {
it("returns 200 with { items: [], hasMore: false } when empty", async () => {
mockChatService.listConversations.mockResolvedValue({ items: [], hasMore: false });
const res = await request(createApp()).get("/api/companies/company-1/conversations");
expect(res.status).toBe(200);
expect(res.body).toEqual({ items: [], hasMore: false });
expect(mockChatService.listConversations).toHaveBeenCalledWith("company-1", expect.any(Object));
});
});
describe("POST /api/companies/:companyId/conversations", () => {
it("returns 201 with the created conversation object", async () => {
const created = {
id: "conv-1",
companyId: "company-1",
title: "Test",
agentId: null,
pinnedAt: null,
archivedAt: null,
deletedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
mockChatService.createConversation.mockResolvedValue(created);
const res = await request(createApp())
.post("/api/companies/company-1/conversations")
.send({ title: "Test" });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "conv-1", title: "Test" });
});
});
describe("GET /api/conversations/:id", () => {
it("returns 200 with conversation object", async () => {
const conv = {
id: "conv-1",
companyId: "company-1",
title: "Test",
agentId: null,
pinnedAt: null,
archivedAt: null,
deletedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
mockChatService.getConversation.mockResolvedValue(conv);
const res = await request(createApp()).get("/api/conversations/conv-1");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: "conv-1" });
});
it("returns 404 when conversation not found", async () => {
mockChatService.getConversation.mockResolvedValue(null);
const res = await request(createApp()).get("/api/conversations/nonexistent");
expect(res.status).toBe(404);
});
});
describe("PATCH /api/conversations/:id", () => {
it("returns 200 with updated conversation", async () => {
const updated = {
id: "conv-1",
companyId: "company-1",
title: "new title",
agentId: null,
pinnedAt: null,
archivedAt: null,
deletedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
mockChatService.updateConversation.mockResolvedValue(updated);
const res = await request(createApp())
.patch("/api/conversations/conv-1")
.send({ title: "new title" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ title: "new title" });
});
});
describe("DELETE /api/conversations/:id", () => {
it("returns 204", async () => {
mockChatService.softDeleteConversation.mockResolvedValue(undefined);
const res = await request(createApp()).delete("/api/conversations/conv-1");
expect(res.status).toBe(204);
});
});
describe("POST /api/conversations/:id/archive", () => {
it("returns 200", async () => {
mockChatService.archiveConversation.mockResolvedValue({ id: "conv-1", archivedAt: "2024-01-01" });
const res = await request(createApp()).post("/api/conversations/conv-1/archive");
expect(res.status).toBe(200);
});
});
describe("POST /api/conversations/:id/unarchive", () => {
it("returns 200", async () => {
mockChatService.unarchiveConversation.mockResolvedValue({ id: "conv-1", archivedAt: null });
const res = await request(createApp()).post("/api/conversations/conv-1/unarchive");
expect(res.status).toBe(200);
});
});
describe("POST /api/conversations/:id/pin", () => {
it("returns 200", async () => {
mockChatService.pinConversation.mockResolvedValue({ id: "conv-1", pinnedAt: "2024-01-01" });
const res = await request(createApp()).post("/api/conversations/conv-1/pin");
expect(res.status).toBe(200);
});
});
describe("POST /api/conversations/:id/unpin", () => {
it("returns 200", async () => {
mockChatService.unpinConversation.mockResolvedValue({ id: "conv-1", pinnedAt: null });
const res = await request(createApp()).post("/api/conversations/conv-1/unpin");
expect(res.status).toBe(200);
});
});
describe("GET /api/conversations/:id/messages", () => {
it("returns 200 with { items: [], hasMore: false }", async () => {
mockChatService.listMessages.mockResolvedValue({ items: [], hasMore: false });
const res = await request(createApp()).get("/api/conversations/conv-1/messages");
expect(res.status).toBe(200);
expect(res.body).toEqual({ items: [], hasMore: false });
});
});
describe("POST /api/conversations/:id/messages", () => {
it("returns 201 with created message", async () => {
const message = {
id: "msg-1",
conversationId: "conv-1",
role: "user",
content: "hello",
agentId: null,
createdAt: "2024-01-01T00:00:00.000Z",
};
mockChatService.addMessage.mockResolvedValue(message);
const res = await request(createApp())
.post("/api/conversations/conv-1/messages")
.send({ role: "user", content: "hello" });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "msg-1", content: "hello" });
});
});
});

View file

@ -24,6 +24,7 @@ import { approvalRoutes } from "./routes/approvals.js";
import { secretRoutes } from "./routes/secrets.js";
import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { chatRoutes } from "./routes/chat.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
@ -156,6 +157,7 @@ export async function createApp(
api.use(secretRoutes(db));
api.use(costRoutes(db));
api.use(activityRoutes(db));
api.use(chatRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(instanceSettingsRoutes(db));

101
server/src/routes/chat.ts Normal file
View file

@ -0,0 +1,101 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { chatService } from "../services/chat.js";
import { validate } from "../middleware/validate.js";
import { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared";
export function chatRoutes(db: Db) {
const router = Router();
const svc = chatService(db);
// GET /api/companies/:companyId/conversations
router.get("/companies/:companyId/conversations", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const cursor = req.query.cursor as string | undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const result = await svc.listConversations(companyId, { cursor, limit });
res.json(result);
});
// POST /api/companies/:companyId/conversations
router.post("/companies/:companyId/conversations", validate(createConversationSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const conversation = await svc.createConversation(companyId, req.body);
res.status(201).json(conversation);
});
// GET /api/conversations/:id
router.get("/conversations/:id", async (req, res) => {
assertBoard(req);
const conversation = await svc.getConversation(req.params.id as string);
if (!conversation) {
res.status(404).json({ error: "Not found" });
return;
}
res.json(conversation);
});
// PATCH /api/conversations/:id
router.patch("/conversations/:id", validate(updateConversationSchema), async (req, res) => {
assertBoard(req);
const conversation = await svc.updateConversation(req.params.id as string, req.body);
res.json(conversation);
});
// DELETE /api/conversations/:id
router.delete("/conversations/:id", async (req, res) => {
assertBoard(req);
await svc.softDeleteConversation(req.params.id as string);
res.status(204).end();
});
// POST /api/conversations/:id/archive
router.post("/conversations/:id/archive", async (req, res) => {
assertBoard(req);
const result = await svc.archiveConversation(req.params.id as string);
res.json(result);
});
// POST /api/conversations/:id/unarchive
router.post("/conversations/:id/unarchive", async (req, res) => {
assertBoard(req);
const result = await svc.unarchiveConversation(req.params.id as string);
res.json(result);
});
// POST /api/conversations/:id/pin
router.post("/conversations/:id/pin", async (req, res) => {
assertBoard(req);
const result = await svc.pinConversation(req.params.id as string);
res.json(result);
});
// POST /api/conversations/:id/unpin
router.post("/conversations/:id/unpin", async (req, res) => {
assertBoard(req);
const result = await svc.unpinConversation(req.params.id as string);
res.json(result);
});
// GET /api/conversations/:id/messages
router.get("/conversations/:id/messages", async (req, res) => {
assertBoard(req);
const cursor = req.query.cursor as string | undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const result = await svc.listMessages(req.params.id as string, { cursor, limit });
res.json(result);
});
// POST /api/conversations/:id/messages
router.post("/conversations/:id/messages", validate(createMessageSchema), async (req, res) => {
assertBoard(req);
const message = await svc.addMessage(req.params.id as string, req.body);
res.status(201).json(message);
});
return router;
}

View file

@ -15,3 +15,4 @@ export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { chatRoutes } from "./chat.js";