449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { GmailConfig } from '../../config/schema.js';
|
|
|
|
// Hoisted mocks so vi.mock factories can reference them
|
|
const { mockMessagesList, mockMessagesGet, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
|
|
mockMessagesList: vi.fn(),
|
|
mockMessagesGet: vi.fn(),
|
|
mockExistsSync: vi.fn(),
|
|
mockReadFileSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('googleapis', () => ({
|
|
google: {
|
|
auth: {
|
|
OAuth2: vi.fn().mockImplementation(() => ({
|
|
setCredentials: vi.fn(),
|
|
})),
|
|
},
|
|
gmail: vi.fn().mockReturnValue({
|
|
users: {
|
|
messages: {
|
|
list: mockMessagesList,
|
|
get: mockMessagesGet,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('fs', async () => {
|
|
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
return {
|
|
...actual,
|
|
existsSync: mockExistsSync,
|
|
readFileSync: mockReadFileSync,
|
|
};
|
|
});
|
|
|
|
import { createGmailTools } from './gmail.js';
|
|
|
|
// ── Test config ─────────────────────────────────────────────────────────────
|
|
|
|
const testConfig: NonNullable<GmailConfig> = {
|
|
enabled: true,
|
|
credentials_file: '/tmp/test-creds.json',
|
|
token_file: '/tmp/test-token.json',
|
|
disable_push: false,
|
|
pubsub_pull_interval: '60s',
|
|
pubsub_max_messages: 10,
|
|
watch_labels: ['INBOX'],
|
|
poll_interval: '300s',
|
|
output: { channel: 'discord', peer: '123' },
|
|
message: '{{from}}: {{subject}}',
|
|
};
|
|
|
|
const fakeCredentials = {
|
|
installed: {
|
|
client_id: 'test-client-id',
|
|
client_secret: 'test-client-secret',
|
|
redirect_uris: ['http://localhost'],
|
|
},
|
|
};
|
|
|
|
const fakeToken = {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
};
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function setupValidAuth() {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockImplementation((path: unknown) => {
|
|
const p = String(path);
|
|
if (p.includes('creds')) {return JSON.stringify(fakeCredentials);}
|
|
if (p.includes('token')) {return JSON.stringify(fakeToken);}
|
|
return '';
|
|
});
|
|
}
|
|
|
|
function mockMessageDetails(id: string, from: string, subject: string, date: string, snippet: string) {
|
|
return {
|
|
data: {
|
|
payload: {
|
|
headers: [
|
|
{ name: 'From', value: from },
|
|
{ name: 'Subject', value: subject },
|
|
{ name: 'Date', value: date },
|
|
],
|
|
},
|
|
snippet,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('createGmailTools', () => {
|
|
it('returns 3 tools with correct names', () => {
|
|
const tools = createGmailTools(testConfig);
|
|
expect(tools).toHaveLength(3);
|
|
expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search', 'gmail.read']);
|
|
});
|
|
|
|
it('tools have descriptions and input schemas', () => {
|
|
const tools = createGmailTools(testConfig);
|
|
for (const tool of tools) {
|
|
expect(tool.description).toBeTruthy();
|
|
expect(tool.inputSchema).toBeDefined();
|
|
expect(tool.inputSchema.type).toBe('object');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gmail.list', () => {
|
|
it('returns error when credentials file missing', async () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
const [listTool] = createGmailTools(testConfig);
|
|
|
|
const result = await listTool.execute({});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Credentials file not found');
|
|
});
|
|
|
|
it('returns error when token file missing', async () => {
|
|
mockExistsSync.mockImplementation((path: unknown) => {
|
|
return String(path).includes('creds');
|
|
});
|
|
mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials));
|
|
const [listTool] = createGmailTools(testConfig);
|
|
|
|
const result = await listTool.execute({});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Token file not found');
|
|
});
|
|
|
|
it('lists recent emails with default params', async () => {
|
|
setupValidAuth();
|
|
mockMessagesList.mockResolvedValue({
|
|
data: {
|
|
messages: [{ id: 'msg1' }, { id: 'msg2' }],
|
|
},
|
|
});
|
|
mockMessagesGet
|
|
.mockResolvedValueOnce(mockMessageDetails('msg1', 'alice@test.com', 'Hello', 'Mon, 10 Feb 2026', 'Hi there'))
|
|
.mockResolvedValueOnce(mockMessageDetails('msg2', 'bob@test.com', 'Meeting', 'Mon, 10 Feb 2026', 'At 3pm'));
|
|
|
|
const [listTool] = createGmailTools(testConfig);
|
|
const result = await listTool.execute({});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('alice@test.com');
|
|
expect(result.output).toContain('Hello');
|
|
expect(result.output).toContain('bob@test.com');
|
|
expect(result.output).toContain('Meeting');
|
|
|
|
expect(mockMessagesList).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'me',
|
|
labelIds: ['INBOX'],
|
|
maxResults: 10,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('respects maxResults and label params', async () => {
|
|
setupValidAuth();
|
|
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
|
|
|
|
const [listTool] = createGmailTools(testConfig);
|
|
await listTool.execute({ maxResults: 5, label: 'SENT' });
|
|
|
|
expect(mockMessagesList).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
labelIds: ['SENT'],
|
|
maxResults: 5,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('handles empty results', async () => {
|
|
setupValidAuth();
|
|
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
|
|
|
|
const [listTool] = createGmailTools(testConfig);
|
|
const result = await listTool.execute({});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toBe('No messages found.');
|
|
});
|
|
|
|
it('sanitizes HTML entities in snippets', async () => {
|
|
setupValidAuth();
|
|
mockMessagesList.mockResolvedValue({
|
|
data: {
|
|
messages: [{ id: 'msg1' }],
|
|
},
|
|
});
|
|
mockMessagesGet.mockResolvedValueOnce(
|
|
mockMessageDetails(
|
|
'msg1',
|
|
'experian@test.com',
|
|
'Credit Alert',
|
|
'Mon, 10 Feb 2026',
|
|
'William, your score is rising's & it… Don't miss out<br>Check now',
|
|
),
|
|
);
|
|
|
|
const [listTool] = createGmailTools(testConfig);
|
|
const result = await listTool.execute({});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).not.toContain(''');
|
|
expect(result.output).not.toContain('&');
|
|
expect(result.output).not.toContain('…');
|
|
expect(result.output).not.toContain('<br>');
|
|
expect(result.output).toContain("rising's");
|
|
expect(result.output).toContain('& it');
|
|
expect(result.output).toContain("Don't miss out");
|
|
});
|
|
});
|
|
|
|
describe('gmail.search', () => {
|
|
it('searches with query parameter', async () => {
|
|
setupValidAuth();
|
|
mockMessagesList.mockResolvedValue({
|
|
data: {
|
|
messages: [{ id: 'msg1' }],
|
|
},
|
|
});
|
|
mockMessagesGet.mockResolvedValueOnce(
|
|
mockMessageDetails('msg1', 'alice@test.com', 'Invoice', 'Mon, 10 Feb 2026', 'Your invoice'),
|
|
);
|
|
|
|
const [, searchTool] = createGmailTools(testConfig);
|
|
const result = await searchTool.execute({ query: 'from:alice subject:invoice' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('Invoice');
|
|
expect(result.output).toContain('alice@test.com');
|
|
|
|
expect(mockMessagesList).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'me',
|
|
q: 'from:alice subject:invoice',
|
|
maxResults: 10,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('respects maxResults param', async () => {
|
|
setupValidAuth();
|
|
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
|
|
|
|
const [, searchTool] = createGmailTools(testConfig);
|
|
await searchTool.execute({ query: 'is:unread', maxResults: 3 });
|
|
|
|
expect(mockMessagesList).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
q: 'is:unread',
|
|
maxResults: 3,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('returns error when credentials missing', async () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
const [, searchTool] = createGmailTools(testConfig);
|
|
|
|
const result = await searchTool.execute({ query: 'test' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Credentials file not found');
|
|
});
|
|
|
|
it('handles API errors gracefully', async () => {
|
|
setupValidAuth();
|
|
mockMessagesList.mockRejectedValue(new Error('API quota exceeded'));
|
|
|
|
const [, searchTool] = createGmailTools(testConfig);
|
|
const result = await searchTool.execute({ query: 'test' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('API quota exceeded');
|
|
});
|
|
});
|
|
|
|
// ── gmail.read ──────────────────────────────────────────────────────────────
|
|
|
|
/** Encode a string as base64url (matching Gmail API format). */
|
|
function toBase64Url(str: string): string {
|
|
return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
describe('gmail.read', () => {
|
|
it('reads full message with plain text body', async () => {
|
|
setupValidAuth();
|
|
const bodyText = 'Hello, this is the full email body with invoice amount $150.00';
|
|
mockMessagesGet.mockResolvedValue({
|
|
data: {
|
|
payload: {
|
|
mimeType: 'text/plain',
|
|
headers: [
|
|
{ name: 'From', value: 'billing@example.com' },
|
|
{ name: 'To', value: 'will@example.com' },
|
|
{ name: 'Subject', value: 'Payment Receipt' },
|
|
{ name: 'Date', value: 'Mon, 10 Feb 2026 12:00:00 -0000' },
|
|
],
|
|
body: { data: toBase64Url(bodyText) },
|
|
},
|
|
},
|
|
});
|
|
|
|
const [, , readTool] = createGmailTools(testConfig);
|
|
const result = await readTool.execute({ id: 'msg123' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('From: billing@example.com');
|
|
expect(result.output).toContain('To: will@example.com');
|
|
expect(result.output).toContain('Subject: Payment Receipt');
|
|
expect(result.output).toContain('$150.00');
|
|
|
|
expect(mockMessagesGet).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'me',
|
|
id: 'msg123',
|
|
format: 'full',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('reads multipart message preferring text/plain', async () => {
|
|
setupValidAuth();
|
|
const plainBody = 'Plain text version of the email';
|
|
const htmlBody = '<html><body><b>HTML version</b></body></html>';
|
|
mockMessagesGet.mockResolvedValue({
|
|
data: {
|
|
payload: {
|
|
mimeType: 'multipart/alternative',
|
|
headers: [
|
|
{ name: 'From', value: 'sender@example.com' },
|
|
{ name: 'To', value: 'will@example.com' },
|
|
{ name: 'Subject', value: 'Multipart Test' },
|
|
{ name: 'Date', value: 'Mon, 10 Feb 2026 12:00:00 -0000' },
|
|
],
|
|
parts: [
|
|
{ mimeType: 'text/html', body: { data: toBase64Url(htmlBody) } },
|
|
{ mimeType: 'text/plain', body: { data: toBase64Url(plainBody) } },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const [, , readTool] = createGmailTools(testConfig);
|
|
const result = await readTool.execute({ id: 'msg456' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('Plain text version of the email');
|
|
expect(result.output).not.toContain('<html>');
|
|
});
|
|
|
|
it('falls back to stripped HTML when no text/plain part', async () => {
|
|
setupValidAuth();
|
|
const htmlBody = '<html><body><p>Amount: $200.00</p></body></html>';
|
|
mockMessagesGet.mockResolvedValue({
|
|
data: {
|
|
payload: {
|
|
mimeType: 'multipart/alternative',
|
|
headers: [
|
|
{ name: 'From', value: 'sender@example.com' },
|
|
{ name: 'To', value: 'will@example.com' },
|
|
{ name: 'Subject', value: 'HTML Only' },
|
|
{ name: 'Date', value: 'Mon, 10 Feb 2026 12:00:00 -0000' },
|
|
],
|
|
parts: [
|
|
{ mimeType: 'text/html', body: { data: toBase64Url(htmlBody) } },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const [, , readTool] = createGmailTools(testConfig);
|
|
const result = await readTool.execute({ id: 'msg789' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('Amount: $200.00');
|
|
expect(result.output).not.toContain('<html>');
|
|
});
|
|
|
|
it('decodes HTML entities in HTML-only body fallback', async () => {
|
|
setupValidAuth();
|
|
const htmlBody = '<html><body><p>Hello & welcome</p><br><p>Price: <$100></p><br><p>It's great</p></body></html>';
|
|
mockMessagesGet.mockResolvedValue({
|
|
data: {
|
|
payload: {
|
|
mimeType: 'multipart/alternative',
|
|
headers: [
|
|
{ name: 'From', value: 'sender@example.com' },
|
|
{ name: 'To', value: 'will@example.com' },
|
|
{ name: 'Subject', value: 'HTML Entities' },
|
|
{ name: 'Date', value: 'Mon, 10 Feb 2026 12:00:00 -0000' },
|
|
],
|
|
parts: [
|
|
{ mimeType: 'text/html', body: { data: toBase64Url(htmlBody) } },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
const [, , readTool] = createGmailTools(testConfig);
|
|
const result = await readTool.execute({ id: 'msg-entities' });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain('Hello & welcome');
|
|
expect(result.output).toContain('Price: <$100>');
|
|
expect(result.output).toContain("It's great");
|
|
expect(result.output).not.toContain('&');
|
|
expect(result.output).not.toContain('<');
|
|
expect(result.output).not.toContain(''');
|
|
});
|
|
|
|
it('returns error when credentials missing', async () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
const [, , readTool] = createGmailTools(testConfig);
|
|
|
|
const result = await readTool.execute({ id: 'msg1' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Credentials file not found');
|
|
});
|
|
|
|
it('handles API errors gracefully', async () => {
|
|
setupValidAuth();
|
|
mockMessagesGet.mockRejectedValue(new Error('Message not found'));
|
|
|
|
const [, , readTool] = createGmailTools(testConfig);
|
|
const result = await readTool.execute({ id: 'nonexistent' });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Message not found');
|
|
});
|
|
});
|