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:
parent
8e16cec7a9
commit
5c969bb9da
4 changed files with 323 additions and 0 deletions
219
server/src/__tests__/chat-routes.test.ts
Normal file
219
server/src/__tests__/chat-routes.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
101
server/src/routes/chat.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue