fetchMessageAttachments downloads a Gmail message's downloadable parts as discord.js file descriptors, skipping parts over Discord's 25 MB ceiling and capping at 10 files per message. Nameless inline parts (CID screenshots) get a synthesized name; nameless text/* parts (the email body Gmail serves as an attachmentId-backed part) are skipped. The poll posts these on both new tickets and follow-ups, naming any skipped parts so staff know to check Gmail.
136 lines
4.7 KiB
JavaScript
136 lines
4.7 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|