552 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|