import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { homedir } from 'os'; import type { GmailWatcher as GmailWatcherType } from './gmail.js'; import type { OutboundMessage } from '../channels/types.js'; // Mock googleapis module vi.mock('googleapis', () => { const mockWatch = vi.fn().mockResolvedValue({ data: { historyId: '12345', expiration: String(Date.now() + 7 * 24 * 60 * 60 * 1000) }, }); const mockHistoryList = vi.fn().mockResolvedValue({ data: { history: [ { messagesAdded: [ { message: { id: 'msg-001' } }, { message: { id: 'msg-002' } }, ], }, ], historyId: '12346', }, }); const mockMessagesGet = vi.fn().mockImplementation(({ id }: { id: string }) => { return Promise.resolve({ data: { id, snippet: 'Hello, this is a test email', labelIds: ['INBOX', 'UNREAD'], payload: { headers: [ { name: 'From', value: 'alice@example.com' }, { name: 'To', value: 'bob@example.com' }, { name: 'Subject', value: 'Test Subject' }, { name: 'Date', value: 'Sat, 07 Feb 2026 10:00:00 +0000' }, ], }, }, }); }); const mockGetProfile = vi.fn().mockResolvedValue({ data: { emailAddress: 'bob@example.com', historyId: '12344' }, }); const mockOAuth2 = vi.fn().mockImplementation(() => ({ setCredentials: vi.fn(), on: vi.fn(), })); return { google: { auth: { OAuth2: mockOAuth2, }, gmail: vi.fn().mockReturnValue({ users: { watch: mockWatch, getProfile: mockGetProfile, history: { list: mockHistoryList }, messages: { get: mockMessagesGet }, }, }), }, _mocks: { mockWatch, mockHistoryList, mockMessagesGet, mockGetProfile, mockOAuth2, }, }; }); vi.mock('@google-cloud/pubsub', () => { const pull = vi.fn().mockResolvedValue([{ receivedMessages: [] }]); const acknowledge = vi.fn().mockResolvedValue([{}]); const close = vi.fn().mockResolvedValue(undefined); class SubscriberClient { pull = pull; acknowledge = acknowledge; close = close; } return { v1: { SubscriberClient }, _mocks: { pull, acknowledge, close }, }; }); // Mock fs operations vi.mock('fs', async () => { const actual = await vi.importActual('fs'); return { ...actual, existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn().mockImplementation((path: string) => { if (path.includes('credentials')) { return JSON.stringify({ installed: { client_id: 'test-client-id', client_secret: 'test-client-secret', project_id: 'test-project', redirect_uris: ['http://localhost'], }, }); } // Token file return JSON.stringify({ access_token: 'test-access-token', refresh_token: 'test-refresh-token', expiry_date: Date.now() + 3600000, }); }), writeFileSync: vi.fn(), mkdirSync: vi.fn(), chmodSync: vi.fn(), }; }); function createMockConfig(overrides = {}) { return { enabled: true, credentials_file: '~/.config/flynn/gmail-credentials.json', token_file: '~/.config/flynn/gmail-token.json', disable_push: false, pubsub_pull_interval: '60s', pubsub_max_messages: 10, watch_labels: ['INBOX'], poll_interval: '300s', output: { channel: 'telegram', peer: '12345', }, message: 'New email from {{from}}: {{subject}}\n\n{{snippet}}', ...overrides, }; } function createMockChannelLookup() { const mockSend = vi.fn().mockResolvedValue(undefined); return { get: vi.fn().mockReturnValue({ send: mockSend }), _mockSend: mockSend, }; } describe('GmailWatcher', () => { let GmailWatcher: typeof GmailWatcherType; let watcher: GmailWatcherType; let channelLookup: ReturnType; beforeEach(async () => { vi.useFakeTimers(); channelLookup = createMockChannelLookup(); // Import after mocks so ESM named imports (fs/googleapis) are properly mocked. ({ GmailWatcher } = await import('./gmail.js')); }); afterEach(async () => { if (watcher) { await watcher.disconnect(); } vi.useRealTimers(); vi.restoreAllMocks(); }); describe('construction', () => { it('creates with valid config', () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); expect(watcher.name).toBe('gmail'); expect(watcher.status).toBe('disconnected'); }); }); describe('push topic resolution', () => { it('returns null when pubsub_topic is not set', () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const topic = (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName(); expect(topic).toBe(null); }); it('expands shorthand topic id when project_id is known', () => { const config = createMockConfig({ pubsub_topic: 'my-topic' }); watcher = new GmailWatcher(config, channelLookup); (watcher as unknown as { googleProjectId: string }).googleProjectId = 'test-project'; const topic = (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName(); expect(topic).toBe('projects/test-project/topics/my-topic'); }); it('rejects invalid pubsub_topic formats', () => { const config = createMockConfig({ pubsub_topic: 'projects/test-project/topic/my-topic' }); watcher = new GmailWatcher(config, channelLookup); expect(() => { (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName(); }).toThrow(/Invalid pubsub_topic/); }); }); describe('pull subscription resolution', () => { it('returns null when pubsub_subscription_id is not set', () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const sub = (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName(); expect(sub).toBe(null); }); it('expands shorthand subscription id when project_id is known', () => { const config = createMockConfig({ pubsub_subscription_id: 'my-sub' }); watcher = new GmailWatcher(config, channelLookup); (watcher as unknown as { googleProjectId: string }).googleProjectId = 'test-project'; const sub = (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName(); expect(sub).toBe('projects/test-project/subscriptions/my-sub'); }); it('rejects invalid pubsub_subscription_id formats', () => { const config = createMockConfig({ pubsub_subscription_id: 'projects/test-project/subscription/my-sub' }); watcher = new GmailWatcher(config, channelLookup); expect(() => { (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName(); }).toThrow(/Invalid pubsub_subscription_id/); }); }); describe('connect() with missing credentials', () => { it('logs warning and sets status to error when credentials_file is missing', async () => { const config = createMockConfig({ credentials_file: undefined }); watcher = new GmailWatcher(config, channelLookup); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); await watcher.connect(); expect(watcher.status).toBe('error'); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining('GmailWatcher: Authorization failed'), ); // The actual error includes instructions const calls = errorSpy.mock.calls.flat().join(' '); expect(calls).toContain('flynn gmail-auth'); errorSpy.mockRestore(); }); it('logs warning when token file does not exist', async () => { const { existsSync } = await import('fs'); const mockExistsSync = vi.mocked(existsSync); // credentials file exists but token file does not mockExistsSync.mockImplementation((path: unknown) => { const p = String(path); if (p.includes('credentials')) {return true;} if (p.includes('token')) {return false;} return true; }); const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); await watcher.connect(); expect(watcher.status).toBe('error'); const calls = errorSpy.mock.calls.flat().join(' '); expect(calls).toContain('flynn gmail-auth'); errorSpy.mockRestore(); }); }); describe('push disable flag', () => { it('skips watch setup when disable_push is true', async () => { const config = createMockConfig({ disable_push: true, pubsub_topic: 'projects/test-project/topics/gmail-push' }); watcher = new GmailWatcher(config, channelLookup); const { existsSync, readFileSync } = await import('fs'); vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockImplementation((path: unknown) => { const p = String(path); if (p.includes('credentials')) { return JSON.stringify({ installed: { client_id: 'test-client-id', client_secret: 'test-client-secret', project_id: 'test-project', redirect_uris: ['http://localhost'], }, }); } return JSON.stringify({ access_token: 'test-access-token', refresh_token: 'test-refresh-token', expiry_date: Date.now() + 3600000, }); }); const googleapis = await import('googleapis') as unknown as { _mocks: { mockWatch: ReturnType; mockOAuth2: ReturnType; }; }; googleapis._mocks.mockOAuth2.mockImplementation(() => ({ setCredentials: vi.fn(), on: vi.fn(), })); const watchSpy = googleapis._mocks.mockWatch; await watcher.connect(); expect(watchSpy).not.toHaveBeenCalled(); expect(watcher.status).toBe('connected'); }); }); describe('pullSubscriptionMessages', () => { it('pulls messages and acknowledges successfully processed ones', async () => { const config = createMockConfig({ pubsub_subscription_id: 'projects/test-project/subscriptions/gmail-pull' }); watcher = new GmailWatcher(config, channelLookup); const { _mocks: pubsubMocks } = await import('@google-cloud/pubsub') as unknown as { _mocks: { pull: ReturnType; acknowledge: ReturnType }; }; const payload = { emailAddress: 'bob@example.com', historyId: '200' }; pubsubMocks.pull.mockResolvedValueOnce([ { receivedMessages: [ { ackId: 'ack-1', message: { data: Buffer.from(JSON.stringify(payload)) } }, ], }, ]); await (watcher as unknown as { pullSubscriptionMessages: () => Promise }).pullSubscriptionMessages(); expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200'); expect(pubsubMocks.acknowledge).toHaveBeenCalledWith({ subscription: 'projects/test-project/subscriptions/gmail-pull', ackIds: ['ack-1'], }); }); }); describe('renderTemplate', () => { it('replaces all placeholders correctly', () => { const config = createMockConfig({ message: 'From: {{from}} To: {{to}} Subject: {{subject}} Snippet: {{snippet}} Date: {{date}} ID: {{id}} Labels: {{labels}}', }); watcher = new GmailWatcher(config, channelLookup); const email = { id: 'msg-123', from: 'alice@example.com', to: 'bob@example.com', subject: 'Hello!', snippet: 'How are you?', date: '2026-02-07T10:00:00Z', labels: ['INBOX', 'UNREAD'], }; const result = watcher.renderTemplate(email); expect(result).toBe( 'From: alice@example.com To: bob@example.com Subject: Hello! Snippet: How are you? Date: 2026-02-07T10:00:00Z ID: msg-123 Labels: INBOX, UNREAD', ); }); it('handles missing fields gracefully', () => { const config = createMockConfig({ message: '{{from}}: {{subject}}', }); watcher = new GmailWatcher(config, channelLookup); const email = { id: '', from: '', to: '', subject: '', snippet: '', date: '', labels: [], }; const result = watcher.renderTemplate(email); expect(result).toBe(': '); }); it('replaces multiple occurrences of the same placeholder', () => { const config = createMockConfig({ message: '{{from}} sent: {{subject}} (from {{from}})', }); watcher = new GmailWatcher(config, channelLookup); const email = { id: 'msg-1', from: 'alice@example.com', to: 'bob@example.com', subject: 'Hi', snippet: '', date: '', labels: [], }; const result = watcher.renderTemplate(email); expect(result).toBe('alice@example.com sent: Hi (from alice@example.com)'); }); }); describe('expandPath', () => { it('expands ~ to homedir', () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const result = watcher.expandPath('~/some/path'); expect(result).toBe(`${homedir()}/some/path`); }); it('expands bare ~', () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const result = watcher.expandPath('~'); expect(result).toBe(homedir()); }); it('resolves absolute paths unchanged', () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const result = watcher.expandPath('/tmp/test.json'); expect(result).toBe('/tmp/test.json'); }); }); describe('handlePushNotification', () => { it('decodes base64 data and updates historyId', async () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); // Set initial historyId via direct access (watcher as unknown as { lastHistoryId: string }).lastHistoryId = '100'; const notification = { emailAddress: 'bob@example.com', historyId: '200' }; const encoded = Buffer.from(JSON.stringify(notification)).toString('base64'); await watcher.handlePushNotification(encoded); // historyId should be updated expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200'); }); it('ignores stale historyId', async () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); (watcher as unknown as { lastHistoryId: string }).lastHistoryId = '200'; const notification = { emailAddress: 'bob@example.com', historyId: '100' }; const encoded = Buffer.from(JSON.stringify(notification)).toString('base64'); await watcher.handlePushNotification(encoded); // historyId should NOT change expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200'); }); it('handles invalid base64 gracefully', async () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); await watcher.handlePushNotification('not-valid-json-after-decode!!!'); // Should not throw — just log error expect(errorSpy).toHaveBeenCalled(); errorSpy.mockRestore(); }); }); describe('disconnect', () => { it('clears timers and sets status to disconnected', async () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); // Manually set status and timers (watcher as unknown as { _status: string })._status = 'connected'; (watcher as unknown as { pollTimer: ReturnType }).pollTimer = setInterval(() => {}, 60000); (watcher as unknown as { watchTimer: ReturnType }).watchTimer = setInterval(() => {}, 60000); await watcher.disconnect(); expect(watcher.status).toBe('disconnected'); expect((watcher as unknown as { pollTimer: unknown }).pollTimer).toBeUndefined(); expect((watcher as unknown as { watchTimer: unknown }).watchTimer).toBeUndefined(); }); }); describe('send', () => { it('routes to output channel via ChannelLookup', async () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const message: OutboundMessage = { text: 'Reply text' }; await watcher.send('some-peer', message); expect(channelLookup.get).toHaveBeenCalledWith('telegram'); expect(channelLookup._mockSend).toHaveBeenCalledWith('12345', message); }); it('warns when output channel not found', async () => { const config = createMockConfig(); const emptyLookup = { get: vi.fn().mockReturnValue(undefined) }; watcher = new GmailWatcher(config, emptyLookup); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await watcher.send('some-peer', { text: 'test' }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Output channel 'telegram' not found"), ); warnSpy.mockRestore(); }); }); describe('onMessage', () => { it('registers message handler', () => { const config = createMockConfig(); watcher = new GmailWatcher(config, channelLookup); const handler = vi.fn(); watcher.onMessage(handler); // Verify handler is stored (indirectly, via processHistoryChanges) expect((watcher as unknown as { messageHandler: unknown }).messageHandler).toBe(handler); }); }); });