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('fs'); return { ...actual, existsSync: mockExistsSync, readFileSync: mockReadFileSync, }; }); import { createGmailTools } from './gmail.js'; // ── Test config ───────────────────────────────────────────────────────────── const testConfig: NonNullable = { enabled: true, credentials_file: '/tmp/test-creds.json', token_file: '/tmp/test-token.json', 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
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('
'); 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 version'; 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(''); }); it('falls back to stripped HTML when no text/plain part', async () => { setupValidAuth(); const htmlBody = '

Amount: $200.00

'; 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(''); }); it('decodes HTML entities in HTML-only body fallback', async () => { setupValidAuth(); const htmlBody = '

Hello & welcome


Price: <$100>


It's great

'; 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'); }); });