feat(tools): add Google Docs, Drive, and Tasks read-only tools
Add three new Google service integrations following the established Gmail/GCal pattern: - Google Docs (docs.list, docs.search, docs.read): list, search, and read document content as plain text via Docs + Drive APIs - Google Drive (drive.list, drive.search, drive.read): list, search, and read files with export support for Workspace files (Docs→text, Sheets→CSV, Slides→text) - Google Tasks (tasks.lists, tasks.list): list task lists and tasks with status, due dates, and notes Each service has its own config section, OAuth auth command, tool policy group, and test suite (53 new tests). The setup wizard now offers to configure all Google services together and run OAuth auth flows automatically after saving config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { GtasksConfig } from '../../config/schema.js';
|
||||
|
||||
// Hoisted mocks so vi.mock factories can reference them
|
||||
const { mockTasklistsList, mockTasksList, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
|
||||
mockTasklistsList: vi.fn(),
|
||||
mockTasksList: vi.fn(),
|
||||
mockExistsSync: vi.fn(),
|
||||
mockReadFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('googleapis', () => ({
|
||||
google: {
|
||||
auth: {
|
||||
OAuth2: vi.fn().mockImplementation(() => ({
|
||||
setCredentials: vi.fn(),
|
||||
})),
|
||||
},
|
||||
tasks: vi.fn().mockReturnValue({
|
||||
tasklists: {
|
||||
list: mockTasklistsList,
|
||||
},
|
||||
tasks: {
|
||||
list: mockTasksList,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
};
|
||||
});
|
||||
|
||||
import { createGtasksTools } from './gtasks.js';
|
||||
|
||||
// ── Test config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testConfig: NonNullable<GtasksConfig> = {
|
||||
enabled: true,
|
||||
credentials_file: '/tmp/test-creds.json',
|
||||
token_file: '/tmp/test-token.json',
|
||||
};
|
||||
|
||||
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 '';
|
||||
});
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createGtasksTools', () => {
|
||||
it('returns 2 tools with correct names', () => {
|
||||
const tools = createGtasksTools(testConfig);
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map(t => t.name)).toEqual(['tasks.lists', 'tasks.list']);
|
||||
});
|
||||
|
||||
it('tools have descriptions and input schemas', () => {
|
||||
const tools = createGtasksTools(testConfig);
|
||||
for (const tool of tools) {
|
||||
expect(tool.description).toBeTruthy();
|
||||
expect(tool.inputSchema).toBeDefined();
|
||||
expect(tool.inputSchema.type).toBe('object');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tasks.lists', () => {
|
||||
it('returns error when credentials file missing', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const [listsTool] = createGtasksTools(testConfig);
|
||||
|
||||
const result = await listsTool.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 [listsTool] = createGtasksTools(testConfig);
|
||||
|
||||
const result = await listsTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Token file not found');
|
||||
});
|
||||
|
||||
it('lists task lists', async () => {
|
||||
setupValidAuth();
|
||||
mockTasklistsList.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'list1', title: 'My Tasks', updated: '2026-02-10T10:00:00Z' },
|
||||
{ id: 'list2', title: 'Work', updated: '2026-02-09T15:00:00Z' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const [listsTool] = createGtasksTools(testConfig);
|
||||
const result = await listsTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('My Tasks');
|
||||
expect(result.output).toContain('Work');
|
||||
expect(result.output).toContain('list1');
|
||||
expect(result.output).toContain('list2');
|
||||
});
|
||||
|
||||
it('handles empty results', async () => {
|
||||
setupValidAuth();
|
||||
mockTasklistsList.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const [listsTool] = createGtasksTools(testConfig);
|
||||
const result = await listsTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('No task lists found.');
|
||||
});
|
||||
|
||||
it('respects maxResults parameter', async () => {
|
||||
setupValidAuth();
|
||||
mockTasklistsList.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const [listsTool] = createGtasksTools(testConfig);
|
||||
await listsTool.execute({ maxResults: 5 });
|
||||
|
||||
expect(mockTasklistsList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResults: 5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
setupValidAuth();
|
||||
mockTasklistsList.mockRejectedValue(new Error('API quota exceeded'));
|
||||
|
||||
const [listsTool] = createGtasksTools(testConfig);
|
||||
const result = await listsTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API quota exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tasks.list', () => {
|
||||
it('lists tasks from default list', async () => {
|
||||
setupValidAuth();
|
||||
mockTasksList.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{ id: 'task1', title: 'Buy groceries', status: 'needsAction', due: '2026-02-11T00:00:00Z', notes: 'Milk, bread', parent: '' },
|
||||
{ id: 'task2', title: 'Call dentist', status: 'completed', due: '', notes: '', parent: '' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const [, listTool] = createGtasksTools(testConfig);
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('[ ] Buy groceries');
|
||||
expect(result.output).toContain('Due: 2026-02-11');
|
||||
expect(result.output).toContain('Notes: Milk, bread');
|
||||
expect(result.output).toContain('[x] Call dentist');
|
||||
|
||||
expect(mockTasksList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tasklist: '@default',
|
||||
showCompleted: true,
|
||||
showHidden: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses specified taskListId', async () => {
|
||||
setupValidAuth();
|
||||
mockTasksList.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const [, listTool] = createGtasksTools(testConfig);
|
||||
await listTool.execute({ taskListId: 'list123' });
|
||||
|
||||
expect(mockTasksList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tasklist: 'list123' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('respects showCompleted parameter', async () => {
|
||||
setupValidAuth();
|
||||
mockTasksList.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const [, listTool] = createGtasksTools(testConfig);
|
||||
await listTool.execute({ showCompleted: false });
|
||||
|
||||
expect(mockTasksList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ showCompleted: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty results', async () => {
|
||||
setupValidAuth();
|
||||
mockTasksList.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const [, listTool] = createGtasksTools(testConfig);
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('No tasks found.');
|
||||
});
|
||||
|
||||
it('returns error when credentials missing', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const [, listTool] = createGtasksTools(testConfig);
|
||||
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Credentials file not found');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
setupValidAuth();
|
||||
mockTasksList.mockRejectedValue(new Error('Not Found'));
|
||||
|
||||
const [, listTool] = createGtasksTools(testConfig);
|
||||
const result = await listTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Not Found');
|
||||
});
|
||||
|
||||
it('respects maxResults parameter', async () => {
|
||||
setupValidAuth();
|
||||
mockTasksList.mockResolvedValue({ data: { items: [] } });
|
||||
|
||||
const [, listTool] = createGtasksTools(testConfig);
|
||||
await listTool.execute({ maxResults: 50 });
|
||||
|
||||
expect(mockTasksList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResults: 50 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user