Post inbound email attachments to the ticket channel
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.
This commit is contained in:
135
tests/gmailAttachments.test.js
Normal file
135
tests/gmailAttachments.test.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user