Files
flynn/src/automation/gmail.test.ts
T

552 lines
18 KiB
TypeScript

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<typeof import('fs')>('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<typeof createMockChannelLookup>;
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<typeof vi.fn>;
mockOAuth2: ReturnType<typeof vi.fn>;
};
};
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<typeof vi.fn>; acknowledge: ReturnType<typeof vi.fn> };
};
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<void> }).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<typeof setInterval> }).pollTimer = setInterval(() => {}, 60000);
(watcher as unknown as { watchTimer: ReturnType<typeof setInterval> }).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);
});
});
});