Files
flynn/src/tools/builtin/gdrive.test.ts
T
William Valentin f204ff1dd7 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>
2026-02-10 12:59:15 -08:00

390 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { GdriveConfig } from '../../config/schema.js';
// Hoisted mocks so vi.mock factories can reference them
const { mockFilesList, mockFilesGet, mockFilesExport, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
mockFilesList: vi.fn(),
mockFilesGet: vi.fn(),
mockFilesExport: vi.fn(),
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
}));
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
drive: vi.fn().mockReturnValue({
files: {
list: mockFilesList,
get: mockFilesGet,
export: mockFilesExport,
},
}),
},
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
};
});
import { createGdriveTools } from './gdrive.js';
// ── Test config ─────────────────────────────────────────────────────────────
const testConfig: NonNullable<GdriveConfig> = {
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 '';
});
}
function mockDriveFile(
id: string,
name: string,
mimeType: string,
opts?: { modifiedTime?: string; size?: string; owners?: string[]; webViewLink?: string },
) {
return {
id,
name,
mimeType,
modifiedTime: opts?.modifiedTime ?? '2026-02-10T12:00:00Z',
size: opts?.size,
owners: (opts?.owners ?? ['owner@test.com']).map(email => ({ emailAddress: email })),
webViewLink: opts?.webViewLink ?? `https://drive.google.com/file/d/${id}/view`,
};
}
// ═════════════════════════════════════════════════════════════════════════════
beforeEach(() => {
vi.clearAllMocks();
});
describe('createGdriveTools', () => {
it('returns 3 tools with correct names', () => {
const tools = createGdriveTools(testConfig);
expect(tools).toHaveLength(3);
expect(tools.map(t => t.name)).toEqual(['drive.list', 'drive.search', 'drive.read']);
});
it('tools have descriptions and input schemas', () => {
const tools = createGdriveTools(testConfig);
for (const tool of tools) {
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe('object');
}
});
});
describe('drive.list', () => {
it('returns error when credentials file missing', async () => {
mockExistsSync.mockReturnValue(false);
const [listTool] = createGdriveTools(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] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Token file not found');
});
it('lists recent files', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({
data: {
files: [
mockDriveFile('f1', 'Report.pdf', 'application/pdf', { size: '1048576' }),
mockDriveFile('f2', 'Notes', 'application/vnd.google-apps.document'),
],
},
});
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('Report.pdf');
expect(result.output).toContain('PDF');
expect(result.output).toContain('1.0 MB');
expect(result.output).toContain('Notes');
expect(result.output).toContain('Google Doc');
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: 'trashed=false',
pageSize: 10,
orderBy: 'modifiedTime desc',
}),
);
});
it('handles empty results', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No files found.');
});
it('respects maxResults parameter', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
await listTool.execute({ maxResults: 5 });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 5 }),
);
});
it('filters by mimeType', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
await listTool.execute({ mimeType: 'application/pdf' });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: "trashed=false and mimeType='application/pdf'",
}),
);
});
it('filters by folderId', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [listTool] = createGdriveTools(testConfig);
await listTool.execute({ folderId: 'folder123' });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: "trashed=false and 'folder123' in parents",
}),
);
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesList.mockRejectedValue(new Error('API quota exceeded'));
const [listTool] = createGdriveTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
describe('drive.search', () => {
it('searches with query parameter', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({
data: {
files: [
mockDriveFile('f1', 'Q1 Budget', 'application/vnd.google-apps.spreadsheet'),
],
},
});
const [, searchTool] = createGdriveTools(testConfig);
const result = await searchTool.execute({ query: 'budget' });
expect(result.success).toBe(true);
expect(result.output).toContain('Q1 Budget');
expect(result.output).toContain('Google Sheet');
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("name contains 'budget'"),
}),
);
});
it('escapes single quotes in query', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [, searchTool] = createGdriveTools(testConfig);
await searchTool.execute({ query: "it's a test" });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("it\\'s a test"),
}),
);
});
it('filters by mimeType', async () => {
setupValidAuth();
mockFilesList.mockResolvedValue({ data: { files: [] } });
const [, searchTool] = createGdriveTools(testConfig);
await searchTool.execute({ query: 'report', mimeType: 'application/pdf' });
expect(mockFilesList).toHaveBeenCalledWith(
expect.objectContaining({
q: expect.stringContaining("mimeType='application/pdf'"),
}),
);
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesList.mockRejectedValue(new Error('API quota exceeded'));
const [, searchTool] = createGdriveTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
describe('drive.read', () => {
it('exports Google Docs as plain text', async () => {
setupValidAuth();
mockFilesGet.mockResolvedValue({
data: { id: 'doc1', name: 'My Doc', mimeType: 'application/vnd.google-apps.document', size: null },
});
mockFilesExport.mockResolvedValue({ data: 'Hello from the document' });
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'doc1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: My Doc');
expect(result.output).toContain('Type: Google Doc');
expect(result.output).toContain('Hello from the document');
expect(mockFilesExport).toHaveBeenCalledWith(
{ fileId: 'doc1', mimeType: 'text/plain' },
{ responseType: 'text' },
);
});
it('exports Google Sheets as CSV', async () => {
setupValidAuth();
mockFilesGet.mockResolvedValue({
data: { id: 'sheet1', name: 'Budget', mimeType: 'application/vnd.google-apps.spreadsheet' },
});
mockFilesExport.mockResolvedValue({ data: 'Name,Amount\nAlice,100' });
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'sheet1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: Budget');
expect(result.output).toContain('Type: Google Sheet');
expect(result.output).toContain('Name,Amount');
expect(mockFilesExport).toHaveBeenCalledWith(
{ fileId: 'sheet1', mimeType: 'text/csv' },
{ responseType: 'text' },
);
});
it('downloads plain text files directly', async () => {
setupValidAuth();
mockFilesGet
.mockResolvedValueOnce({
data: { id: 'txt1', name: 'notes.txt', mimeType: 'text/plain', size: '42' },
})
.mockResolvedValueOnce({ data: 'These are my notes' });
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'txt1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: notes.txt');
expect(result.output).toContain('These are my notes');
});
it('reports binary files as unreadable', async () => {
setupValidAuth();
mockFilesGet.mockResolvedValue({
data: { id: 'img1', name: 'photo.jpg', mimeType: 'image/jpeg', size: '500000' },
});
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'img1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Name: photo.jpg');
expect(result.output).toContain('Binary file');
});
it('returns error when credentials missing', async () => {
mockExistsSync.mockReturnValue(false);
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'doc1' });
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockFilesGet.mockRejectedValue(new Error('File not found'));
const [, , readTool] = createGdriveTools(testConfig);
const result = await readTool.execute({ fileId: 'nonexistent' });
expect(result.success).toBe(false);
expect(result.error).toContain('File not found');
});
});