import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { sendResponse, parseAttachments, validateAttachPath } from "../../src/discord/send-response"; import type { TextChannel, Message } from "discord.js"; function makeChannel() { return { send: vi.fn().mockResolvedValue({ id: "msg-1" }), } as unknown as TextChannel; } function makeMessage() { return {} as Message; } describe("parseAttachments", () => { it("returns empty attachPaths and original text when no markers", () => { const result = parseAttachments("Hello world", "/workspace"); expect(result.text).toBe("Hello world"); expect(result.attachPaths).toHaveLength(0); }); it("extracts a single relative [ATTACH: ...] marker", () => { const result = parseAttachments("Here is the file [ATTACH: images/cat.jpg]", "/workspace"); expect(result.text).toBe("Here is the file"); expect(result.attachPaths).toHaveLength(1); expect(result.attachPaths[0]).toBe(path.resolve("/workspace", "images/cat.jpg")); }); it("extracts an absolute [ATTACH: ...] marker unchanged", () => { const abs = path.resolve("/workspace/file.txt"); const result = parseAttachments(`See [ATTACH: ${abs}]`, "/workspace"); expect(result.attachPaths[0]).toBe(abs); }); it("extracts multiple markers", () => { const result = parseAttachments( "Files: [ATTACH: a.txt] and [ATTACH: b.png]", "/workspace" ); expect(result.attachPaths).toHaveLength(2); expect(result.text).toBe("Files: and"); }); }); describe("validateAttachPath", () => { let tmpDir: string; let tmpFile: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-test-")); tmpFile = path.join(tmpDir, "file.txt"); fs.writeFileSync(tmpFile, "hello"); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("returns null for a valid readable file within workspace", () => { expect(validateAttachPath(tmpFile, tmpDir)).toBeNull(); }); it("rejects paths outside workspace (path traversal)", () => { const outsideFile = path.join(os.tmpdir(), "outside.txt"); fs.writeFileSync(outsideFile, "secret"); const err = validateAttachPath(outsideFile, tmpDir); expect(err).toMatch(/außerhalb/); fs.unlinkSync(outsideFile); }); it("rejects non-existent files", () => { const missing = path.join(tmpDir, "ghost.txt"); const err = validateAttachPath(missing, tmpDir); expect(err).toMatch(/nicht lesbar/); }); }); describe("sendResponse", () => { beforeEach(() => { vi.clearAllMocks(); }); it("sends chunks via channel.send() for text <= 4000 chars", async () => { const channel = makeChannel(); await sendResponse(channel, "Hello, world!", makeMessage()); expect(channel.send).toHaveBeenCalledTimes(1); const call = vi.mocked(channel.send).mock.calls[0][0]; expect(typeof call).toBe("string"); }); it("sends an attachment for text > 4000 chars", async () => { const channel = makeChannel(); await sendResponse(channel, "x".repeat(4001), makeMessage()); expect(channel.send).toHaveBeenCalledTimes(1); const call = vi.mocked(channel.send).mock.calls[0][0]; expect(typeof call).toBe("object"); expect((call as { files: unknown[] }).files).toHaveLength(1); }); it("does not call channel.send() for empty text", async () => { const channel = makeChannel(); await sendResponse(channel, "", makeMessage()); expect(channel.send).not.toHaveBeenCalled(); }); it("sends [ATTACH: ...] file as Discord attachment", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-attach-")); const filePath = path.join(tmpDir, "result.txt"); fs.writeFileSync(filePath, "data"); try { const channel = makeChannel(); await sendResponse(channel, `Here it is [ATTACH: result.txt]`, makeMessage(), tmpDir); // One call for the file, one for the text expect(channel.send).toHaveBeenCalledTimes(2); const fileCall = vi.mocked(channel.send).mock.calls[0][0] as { files: unknown[] }; expect(fileCall.files).toHaveLength(1); const textCall = vi.mocked(channel.send).mock.calls[1][0]; expect(typeof textCall).toBe("string"); expect(textCall as string).toContain("Here it is"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it("sends warning message for file outside workspace", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-ws-")); const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-out-")); const outsideFile = path.join(outsideDir, "secret.txt"); fs.writeFileSync(outsideFile, "secret"); try { const channel = makeChannel(); await sendResponse(channel, `[ATTACH: ${outsideFile}]`, makeMessage(), tmpDir); expect(channel.send).toHaveBeenCalledTimes(1); const call = vi.mocked(channel.send).mock.calls[0][0] as string; expect(call).toMatch(/⚠️/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(outsideDir, { recursive: true, force: true }); } }); it("only sends text when no [ATTACH: ...] present and workspacePath given", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-plain-")); try { const channel = makeChannel(); await sendResponse(channel, "Plain text response", makeMessage(), tmpDir); expect(channel.send).toHaveBeenCalledTimes(1); expect(vi.mocked(channel.send).mock.calls[0][0]).toBe("Plain text response"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); });