import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; import { Readable } from "node:stream"; import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; import { chatFileRoutes } from "../routes/chat-files.js"; import type { StorageService } from "../storage/types.js"; const { mockCreate, mockGetById, mockListByConversation, mockCreateReference, mockAttachToMessage } = vi.hoisted( () => ({ mockCreate: vi.fn(), mockGetById: vi.fn(), mockListByConversation: vi.fn(), mockCreateReference: vi.fn(), mockAttachToMessage: vi.fn(), }), ); const { mockGetConversation } = vi.hoisted(() => ({ mockGetConversation: vi.fn(), })); vi.mock("../services/chat-files.js", () => ({ chatFileService: vi.fn(() => ({ create: mockCreate, getById: mockGetById, listByConversation: mockListByConversation, createReference: mockCreateReference, attachToMessage: mockAttachToMessage, })), deriveCategory: (mimeType: string) => { if (mimeType.startsWith("image/")) return "image"; if (mimeType === "application/pdf" || mimeType.startsWith("text/")) return "document"; return "other"; }, })); vi.mock("../services/chat.js", () => ({ chatService: vi.fn(() => ({ getConversation: mockGetConversation, })), })); function createStorageService(contentType = "image/png"): StorageService { return { provider: "local_disk" as const, putFile: vi.fn(async (input: { companyId: string; namespace: string; originalFilename: string | null; contentType: string; body: Buffer }) => ({ provider: "local_disk" as const, objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, contentType: contentType || input.contentType, byteSize: input.body.length, sha256: "sha256-test", originalFilename: input.originalFilename, })), getObject: vi.fn(), headObject: vi.fn(), deleteObject: vi.fn(), }; } function createApp(storage: StorageService) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = { type: "board", source: "local_implicit", userId: "user-1", }; next(); }); app.use("/api", chatFileRoutes({} as any, storage)); return app; } const FILE_ID = "00000000-0000-0000-0000-000000000001"; const CONV_ID = "00000000-0000-0000-0000-000000000002"; function makeFile() { return { id: FILE_ID, companyId: "company-1", conversationId: CONV_ID, messageId: null, filename: "test.png", originalFilename: "test.png", mimeType: "image/png", sizeBytes: 40, objectKey: "chat-files/test.png", sha256: "sha256-test", source: "user_upload", category: "image", projectId: null, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), }; } describe("chatFileRoutes", () => { beforeEach(() => { vi.clearAllMocks(); mockGetConversation.mockResolvedValue({ id: "conv-1", companyId: "company-1" }); }); afterEach(() => { vi.clearAllMocks(); }); describe("POST /api/conversations/:id/files", () => { it("uploads a file and returns 201 with file and contentPath", async () => { const storage = createStorageService("image/png"); const app = createApp(storage); const mockFile = makeFile(); mockCreate.mockResolvedValue(mockFile); const res = await request(app) .post("/api/conversations/conv-1/files") .attach("file", Buffer.from("png-data"), { filename: "test.png", contentType: "image/png" }); expect(res.status).toBe(201); expect(res.body.contentPath).toBe(`/api/files/${FILE_ID}/content`); expect(res.body.file.id).toBe(FILE_ID); expect(mockCreate).toHaveBeenCalledTimes(1); }); it("returns 400 when no file field is provided", async () => { const storage = createStorageService(); const app = createApp(storage); const res = await request(app) .post("/api/conversations/conv-1/files") .field("source", "user_upload"); expect(res.status).toBe(400); expect(res.body.error).toContain("Missing file field"); }); it("returns 422 when content type is not allowed", async () => { const storage = createStorageService(); const app = createApp(storage); const res = await request(app) .post("/api/conversations/conv-1/files") .attach("file", Buffer.from("video-data"), { filename: "video.mp4", contentType: "video/mp4" }); expect(res.status).toBe(422); expect(res.body.error).toContain("Unsupported file type"); }); it("returns 422 when file exceeds size limit", async () => { const storage = createStorageService(); const app = createApp(storage); const oversized = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1); const res = await request(app) .post("/api/conversations/conv-1/files") .attach("file", oversized, { filename: "big.png", contentType: "image/png" }); expect(res.status).toBe(422); }); }); describe("GET /api/conversations/:id/files", () => { it("returns list of files for the conversation", async () => { const storage = createStorageService(); const app = createApp(storage); const files = [makeFile()]; mockListByConversation.mockResolvedValue(files); const res = await request(app).get("/api/conversations/conv-1/files"); expect(res.status).toBe(200); expect(res.body.items).toHaveLength(1); expect(res.body.items[0].id).toBe(FILE_ID); }); }); describe("GET /api/files/:fileId/content", () => { it("streams file content with correct MIME type", async () => { const storage = createStorageService("image/png"); const app = createApp(storage); const mockFile = makeFile(); mockGetById.mockResolvedValue(mockFile); const imageBytes = Buffer.from("image-bytes"); (storage.getObject as ReturnType).mockResolvedValue({ stream: Readable.from([imageBytes]), contentType: "image/png", contentLength: imageBytes.length, }); const res = await request(app).get(`/api/files/${FILE_ID}/content`); expect(res.status).toBe(200); expect(res.headers["content-type"]).toContain("image/png"); expect(res.headers["x-content-type-options"]).toBe("nosniff"); }); it("returns 404 when file not found", async () => { const storage = createStorageService(); const app = createApp(storage); mockGetById.mockResolvedValue(null); const res = await request(app).get("/api/files/nonexistent/content"); expect(res.status).toBe(404); expect(res.body.error).toContain("not found"); }); }); describe("POST /api/files/:fileId/references", () => { it("creates a cross-conversation reference and returns 201", async () => { const storage = createStorageService(); const app = createApp(storage); const mockFile = makeFile(); mockGetById.mockResolvedValue(mockFile); const reference = { id: "00000000-0000-0000-0000-000000000099", fileId: FILE_ID, conversationId: CONV_ID, messageId: null, createdAt: new Date("2026-01-01T00:00:00.000Z"), }; mockCreateReference.mockResolvedValue(reference); const res = await request(app) .post(`/api/files/${FILE_ID}/references`) .send({ messageId: "00000000-0000-0000-0000-000000000001" }); expect(res.status).toBe(201); expect(res.body.id).toBe("00000000-0000-0000-0000-000000000099"); }); }); describe("PATCH /api/files/:fileId", () => { it("attaches file to a message and returns updated file", async () => { const storage = createStorageService(); const app = createApp(storage); const mockFile = makeFile(); mockGetById.mockResolvedValue(mockFile); const updated = { ...mockFile, messageId: "msg-1" }; mockAttachToMessage.mockResolvedValue(updated); const res = await request(app) .patch(`/api/files/${FILE_ID}`) .send({ messageId: "msg-1" }); expect(res.status).toBe(200); expect(res.body.messageId).toBe("msg-1"); }); it("returns 400 when messageId is missing", async () => { const storage = createStorageService(); const app = createApp(storage); const mockFile = makeFile(); mockGetById.mockResolvedValue(mockFile); const res = await request(app) .patch(`/api/files/${FILE_ID}`) .send({}); expect(res.status).toBe(400); }); }); });