/** * fetchMessageAttachments — Gmail attachment → discord.js file descriptor tests. * * Uses a fake gmail client (no module mocking) so we exercise the real * collectAttachmentParts walk, the base64url→Buffer decode, the size ceiling, * the 10-file cap, and the best-effort skip-on-failure behavior. */ import { describe, it, expect, vi } from 'vitest'; import { collectAttachmentParts, fetchMessageAttachments } from '../services/gmail.js'; // Build a payload part carrying a real attachment (filename + attachmentId). function attPart(filename, attachmentId, size, mimeType = 'image/png') { return { filename, mimeType, body: { attachmentId, size } }; } // Fake gmail whose attachments.get returns base64url of the given string per id. function fakeGmail(dataById) { return { users: { messages: { attachments: { get: vi.fn(async ({ id }) => { if (!(id in dataById)) throw new Error('not found'); const b64url = Buffer.from(dataById[id]).toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); return { data: { data: b64url } }; }) } } } }; } describe('collectAttachmentParts', () => { it('finds attachment parts at any nesting depth, skips inline text', () => { const payload = { parts: [ { mimeType: 'text/plain', body: { data: 'aGk=' } }, { mimeType: 'multipart/mixed', parts: [attPart('log.txt', 'A1', 12)] } ] }; const parts = collectAttachmentParts(payload); expect(parts).toHaveLength(1); expect(parts[0]).toMatchObject({ filename: 'log.txt', attachmentId: 'A1', size: 12 }); }); it('carries over an embedded screenshot that has no filename', () => { const payload = { parts: [ { mimeType: 'image/png', body: { attachmentId: 'CID1', size: 40000 } } ] }; const parts = collectAttachmentParts(payload); expect(parts).toHaveLength(1); expect(parts[0]).toMatchObject({ filename: 'screenshot-1.png', attachmentId: 'CID1' }); }); it('skips a nameless text/html body served as an attachmentId part', () => { const payload = { parts: [ { mimeType: 'text/html', body: { attachmentId: 'BODY', size: 200000 } } ] }; expect(collectAttachmentParts(payload)).toEqual([]); }); it('names a nameless non-image attachment "attachment-N"', () => { const payload = { parts: [ { mimeType: 'application/pdf', body: { attachmentId: 'P1', size: 1000 } } ] }; expect(collectAttachmentParts(payload)[0].filename).toBe('attachment-1.pdf'); }); }); describe('fetchMessageAttachments', () => { it('decodes base64url attachment data into Buffers', async () => { const payload = { parts: [attPart('hello.txt', 'A1', 5)] }; const gmail = fakeGmail({ A1: 'hello' }); const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail); expect(skipped).toEqual([]); expect(files).toHaveLength(1); expect(files[0].name).toBe('hello.txt'); expect(Buffer.isBuffer(files[0].attachment)).toBe(true); expect(files[0].attachment.toString()).toBe('hello'); }); it('skips parts over the 25 MB ceiling without fetching them', async () => { const payload = { parts: [attPart('huge.bin', 'BIG', 26 * 1024 * 1024)] }; const gmail = fakeGmail({ BIG: 'x' }); const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail); expect(files).toEqual([]); expect(skipped).toEqual(['huge.bin']); expect(gmail.users.messages.attachments.get).not.toHaveBeenCalled(); }); it('records a failed download as skipped, keeps the rest', async () => { const payload = { parts: [attPart('ok.txt', 'A1', 2), attPart('gone.txt', 'MISSING', 2)] }; const gmail = fakeGmail({ A1: 'ok' }); const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail); expect(files.map(f => f.name)).toEqual(['ok.txt']); expect(skipped).toEqual(['gone.txt']); }); it('caps at 10 files, skipping the overflow', async () => { const data = {}; const parts = []; for (let i = 0; i < 12; i++) { const id = `A${i}`; data[id] = `f${i}`; parts.push(attPart(`file${i}.txt`, id, 2)); } const { files, skipped } = await fetchMessageAttachments('msg1', { parts }, fakeGmail(data)); expect(files).toHaveLength(10); expect(skipped).toHaveLength(2); }); it('sanitizes CRLF and backticks out of filenames', async () => { const payload = { parts: [attPart('bad\nname`.txt', 'A1', 2)] }; const gmail = fakeGmail({ A1: 'hi' }); const { files } = await fetchMessageAttachments('msg1', payload, gmail); expect(files[0].name).toBe('bad name .txt'); }); });