feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode

This commit is contained in:
William Valentin
2026-02-13 14:55:40 -08:00
parent 8f644d5e25
commit 955b9e28e0
50 changed files with 5955 additions and 160 deletions
+154 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { homedir } from 'os';
import { GmailWatcher } from './gmail.js';
import type { GmailWatcher as GmailWatcherType } from './gmail.js';
import type { OutboundMessage } from '../channels/types.js';
// Mock googleapis module
@@ -74,6 +74,23 @@ vi.mock('googleapis', () => {
};
});
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');
@@ -86,6 +103,7 @@ vi.mock('fs', async () => {
installed: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
project_id: 'test-project',
redirect_uris: ['http://localhost'],
},
});
@@ -108,6 +126,9 @@ function createMockConfig(overrides = {}) {
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: {
@@ -128,12 +149,16 @@ function createMockChannelLookup() {
}
describe('GmailWatcher', () => {
let watcher: GmailWatcher;
let GmailWatcher: typeof GmailWatcherType;
let watcher: GmailWatcherType;
let channelLookup: ReturnType<typeof createMockChannelLookup>;
beforeEach(() => {
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 () => {
@@ -154,6 +179,60 @@ describe('GmailWatcher', () => {
});
});
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 });
@@ -200,6 +279,78 @@ describe('GmailWatcher', () => {
});
});
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({