feat(tools): add Google Calendar tools and register Gmail/GCal in daemon
Add calendar.today, calendar.list, calendar.search tools mirroring the Gmail tool pattern. Includes gcal-auth CLI command, config schema, tool policy entries (messaging/coding profiles + group:gcal), and 17 tests. Also wires up gmail and gcal tool registration in the daemon and TUI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { GmailConfig } from '../../config/schema.js';
|
||||
|
||||
// Hoisted mocks so vi.mock factories can reference them
|
||||
const { mockMessagesList, mockMessagesGet, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
|
||||
mockMessagesList: vi.fn(),
|
||||
mockMessagesGet: vi.fn(),
|
||||
mockExistsSync: vi.fn(),
|
||||
mockReadFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('googleapis', () => ({
|
||||
google: {
|
||||
auth: {
|
||||
OAuth2: vi.fn().mockImplementation(() => ({
|
||||
setCredentials: vi.fn(),
|
||||
})),
|
||||
},
|
||||
gmail: vi.fn().mockReturnValue({
|
||||
users: {
|
||||
messages: {
|
||||
list: mockMessagesList,
|
||||
get: mockMessagesGet,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
};
|
||||
});
|
||||
|
||||
import { createGmailTools } from './gmail.js';
|
||||
|
||||
// ── Test config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testConfig: NonNullable<GmailConfig> = {
|
||||
enabled: true,
|
||||
credentials_file: '/tmp/test-creds.json',
|
||||
token_file: '/tmp/test-token.json',
|
||||
watch_labels: ['INBOX'],
|
||||
poll_interval: '60s',
|
||||
output: { channel: 'discord', peer: '123' },
|
||||
message: '{{from}}: {{subject}}',
|
||||
};
|
||||
|
||||
const fakeCredentials = {
|
||||
installed: {
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
redirect_uris: ['http://localhost'],
|
||||
},
|
||||
};
|
||||
|
||||
const fakeToken = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setupValidAuth() {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockImplementation((path: unknown) => {
|
||||
const p = String(path);
|
||||
if (p.includes('creds')) return JSON.stringify(fakeCredentials);
|
||||
if (p.includes('token')) return JSON.stringify(fakeToken);
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
function mockMessageDetails(id: string, from: string, subject: string, date: string, snippet: string) {
|
||||
return {
|
||||
data: {
|
||||
payload: {
|
||||
headers: [
|
||||
{ name: 'From', value: from },
|
||||
{ name: 'Subject', value: subject },
|
||||
{ name: 'Date', value: date },
|
||||
],
|
||||
},
|
||||
snippet,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createGmailTools', () => {
|
||||
it('returns 2 tools with correct names', () => {
|
||||
const tools = createGmailTools(testConfig);
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search']);
|
||||
});
|
||||
|
||||
it('tools have descriptions and input schemas', () => {
|
||||
const tools = createGmailTools(testConfig);
|
||||
for (const tool of tools) {
|
||||
expect(tool.description).toBeTruthy();
|
||||
expect(tool.inputSchema).toBeDefined();
|
||||
expect(tool.inputSchema.type).toBe('object');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gmail.list', () => {
|
||||
it('returns error when credentials file missing', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const [listTool] = createGmailTools(testConfig);
|
||||
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Credentials file not found');
|
||||
});
|
||||
|
||||
it('returns error when token file missing', async () => {
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
return String(path).includes('creds');
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials));
|
||||
const [listTool] = createGmailTools(testConfig);
|
||||
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Token file not found');
|
||||
});
|
||||
|
||||
it('lists recent emails with default params', async () => {
|
||||
setupValidAuth();
|
||||
mockMessagesList.mockResolvedValue({
|
||||
data: {
|
||||
messages: [{ id: 'msg1' }, { id: 'msg2' }],
|
||||
},
|
||||
});
|
||||
mockMessagesGet
|
||||
.mockResolvedValueOnce(mockMessageDetails('msg1', 'alice@test.com', 'Hello', 'Mon, 10 Feb 2026', 'Hi there'))
|
||||
.mockResolvedValueOnce(mockMessageDetails('msg2', 'bob@test.com', 'Meeting', 'Mon, 10 Feb 2026', 'At 3pm'));
|
||||
|
||||
const [listTool] = createGmailTools(testConfig);
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('alice@test.com');
|
||||
expect(result.output).toContain('Hello');
|
||||
expect(result.output).toContain('bob@test.com');
|
||||
expect(result.output).toContain('Meeting');
|
||||
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'me',
|
||||
labelIds: ['INBOX'],
|
||||
maxResults: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('respects maxResults and label params', async () => {
|
||||
setupValidAuth();
|
||||
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
|
||||
|
||||
const [listTool] = createGmailTools(testConfig);
|
||||
await listTool.execute({ maxResults: 5, label: 'SENT' });
|
||||
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
labelIds: ['SENT'],
|
||||
maxResults: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty results', async () => {
|
||||
setupValidAuth();
|
||||
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
|
||||
|
||||
const [listTool] = createGmailTools(testConfig);
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('No messages found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gmail.search', () => {
|
||||
it('searches with query parameter', async () => {
|
||||
setupValidAuth();
|
||||
mockMessagesList.mockResolvedValue({
|
||||
data: {
|
||||
messages: [{ id: 'msg1' }],
|
||||
},
|
||||
});
|
||||
mockMessagesGet.mockResolvedValueOnce(
|
||||
mockMessageDetails('msg1', 'alice@test.com', 'Invoice', 'Mon, 10 Feb 2026', 'Your invoice'),
|
||||
);
|
||||
|
||||
const [, searchTool] = createGmailTools(testConfig);
|
||||
const result = await searchTool.execute({ query: 'from:alice subject:invoice' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Invoice');
|
||||
expect(result.output).toContain('alice@test.com');
|
||||
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'me',
|
||||
q: 'from:alice subject:invoice',
|
||||
maxResults: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('respects maxResults param', async () => {
|
||||
setupValidAuth();
|
||||
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
|
||||
|
||||
const [, searchTool] = createGmailTools(testConfig);
|
||||
await searchTool.execute({ query: 'is:unread', maxResults: 3 });
|
||||
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
q: 'is:unread',
|
||||
maxResults: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when credentials missing', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const [, searchTool] = createGmailTools(testConfig);
|
||||
|
||||
const result = await searchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Credentials file not found');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
setupValidAuth();
|
||||
mockMessagesList.mockRejectedValue(new Error('API quota exceeded'));
|
||||
|
||||
const [, searchTool] = createGmailTools(testConfig);
|
||||
const result = await searchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API quota exceeded');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user