From db4eb801d37983afee587fd881dcb4811f7fc974 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 23:05:25 +0000 Subject: [PATCH] feat(25-01): create chatFileService with DB operations and deriveCategory helper - Implement chatFileService(db) with create, getById, listByConversation, listByMessage, createReference, listReferences, attachToMessage - Export deriveCategory() helper mapping MIME types to image/code/document/other - Add unit tests verifying service methods and category derivation with mocked DB --- .../src/__tests__/chat-file-service.test.ts | 225 +++++++++++++++++- server/src/services/chat-files.ts | 113 +++++++++ 2 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 server/src/services/chat-files.ts diff --git a/server/src/__tests__/chat-file-service.test.ts b/server/src/__tests__/chat-file-service.test.ts index 1725cf02..0c1e0902 100644 --- a/server/src/__tests__/chat-file-service.test.ts +++ b/server/src/__tests__/chat-file-service.test.ts @@ -1,9 +1,222 @@ -import { describe, it } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { chatFileService, deriveCategory } from "../services/chat-files.js"; + +// Mock @paperclipai/db module +const mockReturning = vi.fn(); +const mockThen = vi.fn(); +const mockLimit = vi.fn(); +const mockOrderBy = vi.fn(); +const mockWhere = vi.fn(); +const mockSet = vi.fn(); +const mockValues = vi.fn(); +const mockInsert = vi.fn(); +const mockSelect = vi.fn(); +const mockFrom = vi.fn(); +const mockUpdate = vi.fn(); + +vi.mock("@paperclipai/db", () => ({ + chatFiles: { id: "id", companyId: "company_id", conversationId: "conversation_id", messageId: "message_id" }, + chatFileReferences: { id: "id", fileId: "file_id" }, +})); + +function makeDb() { + const returning = vi.fn(); + const limit = vi.fn(); + const orderBy = vi.fn(); + const where = vi.fn(); + const set = vi.fn(); + const values = vi.fn(); + const from = vi.fn(); + + // Chain setup + const insertChain = { + values: values.mockReturnValue({ returning }), + }; + + const selectChain = { + from: from.mockReturnValue({ where: where.mockReturnValue({ orderBy: orderBy.mockReturnValue({ limit }), then: undefined as unknown as typeof Promise.prototype.then }) }), + }; + + const updateChain = { + set: set.mockReturnValue({ where: where.mockReturnValue({ returning }) }), + }; + + const db = { + insert: vi.fn().mockReturnValue(insertChain), + select: vi.fn().mockReturnValue(selectChain), + update: vi.fn().mockReturnValue(updateChain), + }; + + return { db, returning, limit, orderBy, where, set, values, from }; +} + +describe("deriveCategory", () => { + it("returns 'image' for image/* mime types", () => { + expect(deriveCategory("image/png")).toBe("image"); + expect(deriveCategory("image/jpeg")).toBe("image"); + expect(deriveCategory("image/gif")).toBe("image"); + expect(deriveCategory("image/webp")).toBe("image"); + expect(deriveCategory("image/svg+xml")).toBe("image"); + }); + + it("returns 'code' for code mime types", () => { + expect(deriveCategory("text/javascript")).toBe("code"); + expect(deriveCategory("text/typescript")).toBe("code"); + expect(deriveCategory("text/css")).toBe("code"); + expect(deriveCategory("text/html")).toBe("code"); + expect(deriveCategory("application/json")).toBe("code"); + expect(deriveCategory("text/x-python")).toBe("code"); + expect(deriveCategory("text/x-java")).toBe("code"); + }); + + it("returns 'document' for document mime types", () => { + expect(deriveCategory("application/pdf")).toBe("document"); + expect(deriveCategory("text/plain")).toBe("document"); + expect(deriveCategory("text/markdown")).toBe("document"); + expect(deriveCategory("text/csv")).toBe("document"); + }); + + it("returns 'other' for unrecognized mime types", () => { + expect(deriveCategory("application/octet-stream")).toBe("other"); + expect(deriveCategory("video/mp4")).toBe("other"); + expect(deriveCategory("audio/mpeg")).toBe("other"); + expect(deriveCategory("application/zip")).toBe("other"); + }); + + it("is case-insensitive", () => { + expect(deriveCategory("IMAGE/PNG")).toBe("image"); + expect(deriveCategory("TEXT/JAVASCRIPT")).toBe("code"); + expect(deriveCategory("APPLICATION/PDF")).toBe("document"); + }); +}); describe("chatFileService", () => { - it.todo("creates a file record after upload"); - it.todo("lists files for a conversation"); - it.todo("lists files for a message"); - it.todo("creates a file reference in another conversation"); - it.todo("returns file with contentPath"); + describe("create", () => { + it("inserts a row and returns the inserted record", async () => { + const mockFile = { + id: "file-1", + companyId: "company-1", + filename: "hello.txt", + originalFilename: "hello.txt", + mimeType: "text/plain", + sizeBytes: 100, + objectKey: "chat-files/hello.txt", + sha256: "abc123", + source: "user_upload", + category: "document", + conversationId: "conv-1", + messageId: null, + projectId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const returningFn = vi.fn().mockResolvedValue([mockFile]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + const insertFn = vi.fn().mockReturnValue({ values: valuesFn }); + + const db = { insert: insertFn } as unknown as Parameters[0]; + const svc = chatFileService(db); + + const result = await svc.create("company-1", { + filename: "hello.txt", + originalFilename: "hello.txt", + mimeType: "text/plain", + sizeBytes: 100, + objectKey: "chat-files/hello.txt", + sha256: "abc123", + source: "user_upload", + category: "document", + conversationId: "conv-1", + messageId: null, + projectId: null, + }); + + expect(result).toEqual(mockFile); + expect(insertFn).toHaveBeenCalled(); + expect(valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ companyId: "company-1", filename: "hello.txt" }), + ); + }); + }); + + describe("getById", () => { + it("returns null when no row found", async () => { + const thenFn = vi.fn().mockImplementation((cb: (rows: unknown[]) => unknown) => Promise.resolve(cb([]))); + const whereFn = vi.fn().mockReturnValue({ then: thenFn }); + const fromFn = vi.fn().mockReturnValue({ where: whereFn }); + const selectFn = vi.fn().mockReturnValue({ from: fromFn }); + + const db = { select: selectFn } as unknown as Parameters[0]; + const svc = chatFileService(db); + + const result = await svc.getById("nonexistent-id"); + expect(result).toBeNull(); + }); + + it("returns the row when found", async () => { + const mockFile = { id: "file-1", companyId: "company-1" }; + const thenFn = vi.fn().mockImplementation((cb: (rows: unknown[]) => unknown) => Promise.resolve(cb([mockFile]))); + const whereFn = vi.fn().mockReturnValue({ then: thenFn }); + const fromFn = vi.fn().mockReturnValue({ where: whereFn }); + const selectFn = vi.fn().mockReturnValue({ from: fromFn }); + + const db = { select: selectFn } as unknown as Parameters[0]; + const svc = chatFileService(db); + + const result = await svc.getById("file-1"); + expect(result).toEqual(mockFile); + }); + }); + + describe("listByConversation", () => { + it("queries by conversationId with default limit 50", async () => { + const mockFiles = [{ id: "file-1" }, { id: "file-2" }]; + const limitFn = vi.fn().mockResolvedValue(mockFiles); + const orderByFn = vi.fn().mockReturnValue({ limit: limitFn }); + const whereFn = vi.fn().mockReturnValue({ orderBy: orderByFn }); + const fromFn = vi.fn().mockReturnValue({ where: whereFn }); + const selectFn = vi.fn().mockReturnValue({ from: fromFn }); + + const db = { select: selectFn } as unknown as Parameters[0]; + const svc = chatFileService(db); + + const result = await svc.listByConversation("conv-1"); + expect(result).toEqual(mockFiles); + expect(limitFn).toHaveBeenCalledWith(50); + }); + + it("respects custom limit", async () => { + const limitFn = vi.fn().mockResolvedValue([]); + const orderByFn = vi.fn().mockReturnValue({ limit: limitFn }); + const whereFn = vi.fn().mockReturnValue({ orderBy: orderByFn }); + const fromFn = vi.fn().mockReturnValue({ where: whereFn }); + const selectFn = vi.fn().mockReturnValue({ from: fromFn }); + + const db = { select: selectFn } as unknown as Parameters[0]; + const svc = chatFileService(db); + + await svc.listByConversation("conv-1", { limit: 10 }); + expect(limitFn).toHaveBeenCalledWith(10); + }); + }); + + describe("attachToMessage", () => { + it("updates messageId and returns updated row", async () => { + const mockFile = { id: "file-1", messageId: "msg-1" }; + const returningFn = vi.fn().mockResolvedValue([mockFile]); + const whereFn = vi.fn().mockReturnValue({ returning: returningFn }); + const setFn = vi.fn().mockReturnValue({ where: whereFn }); + const updateFn = vi.fn().mockReturnValue({ set: setFn }); + + const db = { update: updateFn } as unknown as Parameters[0]; + const svc = chatFileService(db); + + const result = await svc.attachToMessage("file-1", "msg-1"); + expect(result).toEqual(mockFile); + expect(setFn).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "msg-1" }), + ); + }); + }); }); diff --git a/server/src/services/chat-files.ts b/server/src/services/chat-files.ts new file mode 100644 index 00000000..e9d674f7 --- /dev/null +++ b/server/src/services/chat-files.ts @@ -0,0 +1,113 @@ +import { eq, desc, asc } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { chatFiles, chatFileReferences } from "@paperclipai/db"; + +const CODE_MIME_TYPES = new Set([ + "text/javascript", + "text/typescript", + "application/javascript", + "application/typescript", + "text/css", + "text/html", + "application/json", + "text/x-python", + "text/x-java", + "text/x-c", + "text/x-cpp", + "text/x-csharp", + "text/x-ruby", + "text/x-go", + "text/x-rust", + "text/x-swift", + "text/x-kotlin", + "text/x-php", + "text/x-shellscript", + "application/x-sh", + "text/x-yaml", + "application/x-yaml", + "text/x-toml", +]); + +const DOCUMENT_MIME_TYPES = new Set([ + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", +]); + +export function deriveCategory(mimeType: string): string { + const mt = mimeType.toLowerCase(); + if (mt.startsWith("image/")) return "image"; + if (CODE_MIME_TYPES.has(mt)) return "code"; + if (DOCUMENT_MIME_TYPES.has(mt)) return "document"; + return "other"; +} + +export function chatFileService(db: Db) { + return { + create( + companyId: string, + data: Omit, + ) { + return db + .insert(chatFiles) + .values({ ...data, companyId }) + .returning() + .then((rows) => rows[0]!); + }, + + getById(id: string) { + return db + .select() + .from(chatFiles) + .where(eq(chatFiles.id, id)) + .then((rows) => rows[0] ?? null); + }, + + listByConversation(conversationId: string, opts?: { limit?: number }) { + const limit = opts?.limit ?? 50; + return db + .select() + .from(chatFiles) + .where(eq(chatFiles.conversationId, conversationId)) + .orderBy(desc(chatFiles.createdAt)) + .limit(limit); + }, + + listByMessage(messageId: string) { + return db + .select() + .from(chatFiles) + .where(eq(chatFiles.messageId, messageId)) + .orderBy(asc(chatFiles.createdAt)); + }, + + createReference(data: { + fileId: string; + conversationId: string; + messageId?: string; + }) { + return db + .insert(chatFileReferences) + .values(data) + .returning() + .then((rows) => rows[0]!); + }, + + listReferences(fileId: string) { + return db + .select() + .from(chatFileReferences) + .where(eq(chatFileReferences.fileId, fileId)); + }, + + attachToMessage(fileId: string, messageId: string) { + return db + .update(chatFiles) + .set({ messageId, updatedAt: new Date() }) + .where(eq(chatFiles.id, fileId)) + .returning() + .then((rows) => rows[0]!); + }, + }; +}