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
This commit is contained in:
parent
0fe948d1d0
commit
1cf231a540
2 changed files with 332 additions and 6 deletions
|
|
@ -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<typeof chatFileService>[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<typeof chatFileService>[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<typeof chatFileService>[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<typeof chatFileService>[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<typeof chatFileService>[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<typeof chatFileService>[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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
113
server/src/services/chat-files.ts
Normal file
113
server/src/services/chat-files.ts
Normal file
|
|
@ -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<typeof chatFiles.$inferInsert, "companyId" | "id" | "createdAt" | "updatedAt">,
|
||||
) {
|
||||
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]!);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue