feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user