disclaw/tests/unit/attachment-inbox.test.ts
Nick Tabeling 9e03fc5207
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
feat(DIS-154): discord rich features — reactions, file responses, attachment inbox
- Status reactions on messages: 👀 on enqueue,  on success,  on any error
- Typing interval raised from 5s to 8s
- src/discord/send-response.ts: text >4000 chars sent as response.md attachment
- src/discord/attachment-inbox.ts: downloads Discord attachments into .disclaw-inbox/, cleanupInbox() removes files older than 24h
- src/router.ts: input limit guard at 4000 chars (⚠️ reaction + hint message), processAttachments hints injected into prompt
- Unit tests: send-response.test.ts, attachment-inbox.test.ts (107 tests, all green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:58:57 +02:00

144 lines
4.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import type { Message, Collection, Attachment } from "discord.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeMessage(attachments: Attachment[] = []): Message {
const col = new Map(attachments.map((a) => [a.id, a]));
return {
attachments: {
size: attachments.length,
values: () => col.values(),
},
} as unknown as Message;
}
function makeAttachment(id: string, name: string, url: string): Attachment {
return { id, name, url } as unknown as Attachment;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("processAttachments", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-inbox-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("returns an empty array when message has no attachments", async () => {
const { processAttachments } = await import("../../src/discord/attachment-inbox");
const message = makeMessage([]);
const hints = await processAttachments(message, tmpDir);
expect(hints).toEqual([]);
// No inbox directory created
expect(fs.existsSync(path.join(tmpDir, ".disclaw-inbox"))).toBe(false);
});
it("saves an attachment to .disclaw-inbox/ and returns a hint", async () => {
// Mock global fetch
const fileContent = "print('hello')\n";
const contentBytes = Buffer.from(fileContent, "utf-8");
// Allocate a clean ArrayBuffer (not the Node shared pool) so Buffer.from() round-trips correctly
const cleanArrayBuffer = contentBytes.buffer.slice(
contentBytes.byteOffset,
contentBytes.byteOffset + contentBytes.byteLength
);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () => cleanArrayBuffer,
})
);
const { processAttachments } = await import("../../src/discord/attachment-inbox");
const attachment = makeAttachment("att-1", "example.py", "https://cdn.discord.com/example.py");
const message = makeMessage([attachment]);
const hints = await processAttachments(message, tmpDir);
expect(hints).toHaveLength(1);
expect(hints[0]).toMatch(/^\[Datei verfügbar: \.disclaw-inbox\//);
expect(hints[0]).toContain("example.py");
// File must exist in inbox
const inboxDir = path.join(tmpDir, ".disclaw-inbox");
const files = fs.readdirSync(inboxDir);
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/example\.py$/);
// File content must match
const savedContent = fs.readFileSync(path.join(inboxDir, files[0]), "utf-8");
expect(savedContent).toBe(fileContent);
});
it("skips an attachment when fetch fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 403,
})
);
const { processAttachments } = await import("../../src/discord/attachment-inbox");
const attachment = makeAttachment("att-1", "secret.txt", "https://cdn.discord.com/secret.txt");
const message = makeMessage([attachment]);
const hints = await processAttachments(message, tmpDir);
expect(hints).toEqual([]);
});
});
describe("cleanupInbox", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-cleanup-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("does nothing when inbox directory does not exist", async () => {
const { cleanupInbox } = await import("../../src/discord/attachment-inbox");
expect(() => cleanupInbox(tmpDir)).not.toThrow();
});
it("deletes files older than 24 hours and keeps newer files", async () => {
const { cleanupInbox } = await import("../../src/discord/attachment-inbox");
const inboxDir = path.join(tmpDir, ".disclaw-inbox");
fs.mkdirSync(inboxDir);
const oldFile = path.join(inboxDir, "old-file.txt");
const newFile = path.join(inboxDir, "new-file.txt");
fs.writeFileSync(oldFile, "old");
fs.writeFileSync(newFile, "new");
// Backdate old file by 25 hours
const oldTime = new Date(Date.now() - 25 * 60 * 60 * 1000);
fs.utimesSync(oldFile, oldTime, oldTime);
cleanupInbox(tmpDir);
expect(fs.existsSync(oldFile)).toBe(false);
expect(fs.existsSync(newFile)).toBe(true);
});
});