feat(21-03): add chatRoutes, mount in app.ts, export chat schemas from shared
- chatRoutes factory with 7 REST endpoints for conversations and messages - All routes gated by assertBoard; company-scoped routes also use assertCompanyAccess - Mounted as api.use(chatRoutes(db)) after activityRoutes in app.ts - Export createConversationSchema/updateConversationSchema/createMessageSchema from @paperclipai/shared (were missing from main package index) - 11 vitest route tests passing
This commit is contained in:
parent
c3c4145f9b
commit
54e1925b9e
4 changed files with 266 additions and 12 deletions
|
|
@ -557,6 +557,15 @@ export {
|
|||
type ListPluginState,
|
||||
} from "./validators/index.js";
|
||||
|
||||
export {
|
||||
createConversationSchema,
|
||||
updateConversationSchema,
|
||||
createMessageSchema,
|
||||
type CreateConversation,
|
||||
type UpdateConversation,
|
||||
type CreateMessage,
|
||||
} from "./validators/index.js";
|
||||
|
||||
export { API_PREFIX, API } from "./api.js";
|
||||
export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js";
|
||||
export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from "./project-url-key.js";
|
||||
|
|
|
|||
|
|
@ -1,35 +1,199 @@
|
|||
import { describe, it } from "vitest";
|
||||
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(() => ({
|
||||
createConversation: vi.fn(),
|
||||
listConversations: vi.fn(),
|
||||
getConversation: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
softDeleteConversation: 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("chatRoutes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /companies/:companyId/conversations", () => {
|
||||
it.todo("creates a conversation and returns 201");
|
||||
it.todo("accepts optional title and agentId");
|
||||
it("creates a conversation and returns 201", async () => {
|
||||
const conv = { id: "conv-1", companyId: "company-1", title: null };
|
||||
mockChatService.createConversation.mockResolvedValue(conv);
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/conversations")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual(conv);
|
||||
expect(mockChatService.createConversation).toHaveBeenCalledWith("company-1", {});
|
||||
});
|
||||
|
||||
it("accepts optional title and agentId", async () => {
|
||||
const conv = { id: "conv-1", companyId: "company-1", title: "My Chat" };
|
||||
mockChatService.createConversation.mockResolvedValue(conv);
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/conversations")
|
||||
.send({ title: "My Chat", agentId: "00000000-0000-0000-0000-000000000001" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockChatService.createConversation).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{ title: "My Chat", agentId: "00000000-0000-0000-0000-000000000001" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /companies/:companyId/conversations", () => {
|
||||
it.todo("returns paginated conversation list");
|
||||
it.todo("supports cursor query param");
|
||||
it("returns paginated conversation list", async () => {
|
||||
const list = { items: [{ id: "conv-1" }], hasMore: false, nextCursor: null };
|
||||
mockChatService.listConversations.mockResolvedValue(list);
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/conversations");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(list);
|
||||
expect(mockChatService.listConversations).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
{ cursor: undefined, limit: undefined, includeArchived: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("supports cursor query param", async () => {
|
||||
const list = { items: [], hasMore: false, nextCursor: null };
|
||||
mockChatService.listConversations.mockResolvedValue(list);
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/conversations?cursor=2024-01-01T00:00:00.000Z&limit=10");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockChatService.listConversations).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ cursor: "2024-01-01T00:00:00.000Z", limit: 10 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /conversations/:id", () => {
|
||||
it.todo("returns conversation by id");
|
||||
it.todo("returns 404 for non-existent conversation");
|
||||
it("returns conversation by id", async () => {
|
||||
const conv = { id: "conv-1", companyId: "company-1" };
|
||||
mockChatService.getConversation.mockResolvedValue(conv);
|
||||
|
||||
const res = await request(createApp()).get("/api/conversations/conv-1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(conv);
|
||||
expect(mockChatService.getConversation).toHaveBeenCalledWith("conv-1");
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent conversation", async () => {
|
||||
const { HttpError } = await import("../errors.js");
|
||||
mockChatService.getConversation.mockRejectedValue(
|
||||
new HttpError(404, "Conversation not found"),
|
||||
);
|
||||
|
||||
const res = await request(createApp()).get("/api/conversations/no-such-id");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /conversations/:id", () => {
|
||||
it.todo("updates conversation fields");
|
||||
it("updates conversation fields", async () => {
|
||||
const updated = { id: "conv-1", title: "Updated" };
|
||||
mockChatService.updateConversation.mockResolvedValue(updated);
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/conversations/conv-1")
|
||||
.send({ title: "Updated" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(updated);
|
||||
expect(mockChatService.updateConversation).toHaveBeenCalledWith(
|
||||
"conv-1",
|
||||
{ title: "Updated" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /conversations/:id", () => {
|
||||
it.todo("soft-deletes and returns 204");
|
||||
it("soft-deletes and returns 204", async () => {
|
||||
mockChatService.softDeleteConversation.mockResolvedValue({ id: "conv-1" });
|
||||
|
||||
const res = await request(createApp()).delete("/api/conversations/conv-1");
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(mockChatService.softDeleteConversation).toHaveBeenCalledWith("conv-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /conversations/:id/messages", () => {
|
||||
it.todo("creates a message and returns 201");
|
||||
it.todo("rejects invalid role");
|
||||
it("creates a message and returns 201", async () => {
|
||||
const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hello" };
|
||||
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).toEqual(message);
|
||||
expect(mockChatService.addMessage).toHaveBeenCalledWith(
|
||||
"conv-1",
|
||||
{ role: "user", content: "Hello" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid role", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/conversations/conv-1/messages")
|
||||
.send({ role: "invalid-role", content: "Hello" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /conversations/:id/messages", () => {
|
||||
it.todo("returns paginated message list");
|
||||
it("returns paginated message list", async () => {
|
||||
const list = { items: [{ id: "msg-1" }], hasMore: false, nextCursor: null };
|
||||
mockChatService.listMessages.mockResolvedValue(list);
|
||||
|
||||
const res = await request(createApp()).get("/api/conversations/conv-1/messages");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(list);
|
||||
expect(mockChatService.listMessages).toHaveBeenCalledWith(
|
||||
"conv-1",
|
||||
{ cursor: undefined, limit: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
79
server/src/routes/chat.ts
Normal file
79
server/src/routes/chat.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { chatService } from "../services/chat.js";
|
||||
import {
|
||||
createConversationSchema,
|
||||
updateConversationSchema,
|
||||
createMessageSchema,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export function chatRoutes(db: Db): Router {
|
||||
const router = Router();
|
||||
const svc = chatService(db);
|
||||
|
||||
// GET /api/companies/:companyId/conversations
|
||||
router.get("/companies/:companyId/conversations", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, req.params.companyId!);
|
||||
const { cursor, limit, includeArchived } = req.query;
|
||||
const result = await svc.listConversations(req.params.companyId!, {
|
||||
cursor: cursor as string | undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
includeArchived: includeArchived === "true",
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/conversations
|
||||
router.post("/companies/:companyId/conversations", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, req.params.companyId!);
|
||||
const data = createConversationSchema.parse(req.body);
|
||||
const conversation = await svc.createConversation(req.params.companyId!, data);
|
||||
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!);
|
||||
res.json(conversation);
|
||||
});
|
||||
|
||||
// PATCH /api/conversations/:id
|
||||
router.patch("/conversations/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const data = updateConversationSchema.parse(req.body);
|
||||
const conversation = await svc.updateConversation(req.params.id!, data);
|
||||
res.json(conversation);
|
||||
});
|
||||
|
||||
// DELETE /api/conversations/:id
|
||||
router.delete("/conversations/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
await svc.softDeleteConversation(req.params.id!);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/conversations/:id/messages
|
||||
router.get("/conversations/:id/messages", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const { cursor, limit } = req.query;
|
||||
const result = await svc.listMessages(req.params.id!, {
|
||||
cursor: cursor as string | undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/messages
|
||||
router.post("/conversations/:id/messages", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const data = createMessageSchema.parse(req.body);
|
||||
const message = await svc.addMessage(req.params.id!, data);
|
||||
res.status(201).json(message);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue