Spaces:
Sleeping
Sleeping
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | |
| import path from 'node:path'; | |
| import { | |
| WORKSPACE_EXPORT_DEFAULTS, | |
| EXPORT_MIME_TO_EXTENSION, | |
| isTextMimeType, | |
| } from './downloadFile.js'; | |
| // --------------------------------------------------------------------------- | |
| // Pure unit tests (no mocks) | |
| // --------------------------------------------------------------------------- | |
| describe('WORKSPACE_EXPORT_DEFAULTS', () => { | |
| it('should default Google Docs to text/markdown', () => { | |
| expect(WORKSPACE_EXPORT_DEFAULTS['application/vnd.google-apps.document']).toBe('text/markdown'); | |
| }); | |
| it('should default Google Sheets to text/csv', () => { | |
| expect(WORKSPACE_EXPORT_DEFAULTS['application/vnd.google-apps.spreadsheet']).toBe('text/csv'); | |
| }); | |
| it('should default Google Slides to text/plain', () => { | |
| expect(WORKSPACE_EXPORT_DEFAULTS['application/vnd.google-apps.presentation']).toBe( | |
| 'text/plain' | |
| ); | |
| }); | |
| it('should default Google Drawings to image/png', () => { | |
| expect(WORKSPACE_EXPORT_DEFAULTS['application/vnd.google-apps.drawing']).toBe('image/png'); | |
| }); | |
| }); | |
| describe('EXPORT_MIME_TO_EXTENSION', () => { | |
| it('should map text/markdown to .md', () => { | |
| expect(EXPORT_MIME_TO_EXTENSION['text/markdown']).toBe('.md'); | |
| }); | |
| it('should map text/csv to .csv', () => { | |
| expect(EXPORT_MIME_TO_EXTENSION['text/csv']).toBe('.csv'); | |
| }); | |
| it('should map application/pdf to .pdf', () => { | |
| expect(EXPORT_MIME_TO_EXTENSION['application/pdf']).toBe('.pdf'); | |
| }); | |
| }); | |
| describe('isTextMimeType', () => { | |
| it.each([ | |
| ['text/plain', true], | |
| ['text/csv', true], | |
| ['text/markdown', true], | |
| ['text/tab-separated-values', true], | |
| ['text/html', true], | |
| ['application/json', true], | |
| ['application/vnd.google-apps.script+json', true], | |
| ])('should return true for %s', (mime, expected) => { | |
| expect(isTextMimeType(mime)).toBe(expected); | |
| }); | |
| it.each([ | |
| ['application/pdf', false], | |
| ['image/png', false], | |
| ['image/jpeg', false], | |
| ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', false], | |
| ['application/octet-stream', false], | |
| ])('should return false for %s', (mime, expected) => { | |
| expect(isTextMimeType(mime)).toBe(expected); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Integration tests (mocked Drive client + fs + pipeline) | |
| // --------------------------------------------------------------------------- | |
| vi.mock('../../clients.js', () => ({ | |
| getDriveClient: vi.fn(), | |
| })); | |
| vi.mock('node:fs', async (importOriginal) => { | |
| const actual = await importOriginal<typeof import('node:fs')>(); | |
| return { | |
| ...actual, | |
| default: { | |
| ...actual, | |
| mkdirSync: vi.fn(), | |
| createWriteStream: vi.fn(() => 'mock-write-stream'), | |
| statSync: vi.fn(() => ({ size: 2048 })), | |
| readFileSync: vi.fn(() => 'mock-text-content'), | |
| unlinkSync: vi.fn(), | |
| }, | |
| }; | |
| }); | |
| vi.mock('node:stream/promises', () => ({ | |
| pipeline: vi.fn(async () => {}), | |
| })); | |
| import { getDriveClient } from '../../clients.js'; | |
| import fs from 'node:fs'; | |
| import { pipeline } from 'node:stream/promises'; | |
| import { register } from './downloadFile.js'; | |
| import { UserError } from 'fastmcp'; | |
| const mockGetDriveClient = vi.mocked(getDriveClient); | |
| const mockMkdirSync = vi.mocked(fs.mkdirSync); | |
| const mockCreateWriteStream = vi.mocked(fs.createWriteStream); | |
| const mockStatSync = vi.mocked(fs.statSync); | |
| const mockReadFileSync = vi.mocked(fs.readFileSync); | |
| const mockUnlinkSync = vi.mocked(fs.unlinkSync); | |
| const mockPipeline = vi.mocked(pipeline); | |
| let toolExecute: (args: any, context: any) => Promise<string>; | |
| const mockLog = { info: vi.fn(), error: vi.fn(), warn: vi.fn() }; | |
| function createMockDrive(metadataOverrides: Record<string, any> = {}) { | |
| const mockStream = { pipe: vi.fn() }; | |
| const metadata = { | |
| name: 'report.pdf', | |
| mimeType: 'application/pdf', | |
| size: '2048', | |
| ...metadataOverrides, | |
| }; | |
| const filesGet = vi.fn().mockImplementation((params: any) => { | |
| if (params?.alt === 'media') { | |
| return Promise.resolve({ data: mockStream }); | |
| } | |
| return Promise.resolve({ data: metadata }); | |
| }); | |
| const filesExport = vi.fn().mockResolvedValue({ data: mockStream }); | |
| const drive = { | |
| files: { get: filesGet, export: filesExport }, | |
| }; | |
| mockGetDriveClient.mockResolvedValue(drive as any); | |
| return { drive, filesGet, filesExport, mockStream, metadata }; | |
| } | |
| describe('downloadFile integration', () => { | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| mockStatSync.mockReturnValue({ size: 2048 } as any); | |
| mockReadFileSync.mockReturnValue('mock-text-content'); | |
| mockCreateWriteStream.mockReturnValue('mock-write-stream' as any); | |
| mockPipeline.mockResolvedValue(undefined); | |
| const fakeServer = { addTool: (config: any) => (toolExecute = config.execute) }; | |
| register(fakeServer as any); | |
| }); | |
| describe('blob download', () => { | |
| it('should call files.get with correct params and NOT call files.export', async () => { | |
| const { filesGet, filesExport } = createMockDrive(); | |
| const result = await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/report.pdf' }, | |
| { log: mockLog } | |
| ); | |
| // Metadata call includes supportsAllDrives | |
| expect(filesGet).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| fileId: 'f1', | |
| fields: 'name,mimeType,size', | |
| supportsAllDrives: true, | |
| }) | |
| ); | |
| // Download call has alt: 'media' and supportsAllDrives | |
| expect(filesGet).toHaveBeenCalledWith( | |
| expect.objectContaining({ fileId: 'f1', alt: 'media', supportsAllDrives: true }), | |
| expect.objectContaining({ responseType: 'stream' }) | |
| ); | |
| // files.export must NOT be called for blob files | |
| expect(filesExport).not.toHaveBeenCalled(); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.savedTo).toBe(path.resolve('./downloads/report.pdf')); | |
| expect(parsed.fileName).toBe('report.pdf'); | |
| expect(parsed.sizeBytes).toBe(2048); | |
| expect(parsed.originalMimeType).toBe('application/pdf'); | |
| expect(parsed.exportedAs).toBeUndefined(); | |
| }); | |
| }); | |
| describe('workspace export', () => { | |
| it('should call files.export with default MIME and NOT call files.get with alt media', async () => { | |
| const { filesGet, filesExport } = createMockDrive({ | |
| name: 'My Doc', | |
| mimeType: 'application/vnd.google-apps.document', | |
| }); | |
| const result = await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/doc.md' }, | |
| { log: mockLog } | |
| ); | |
| // files.export called with default markdown MIME | |
| expect(filesExport).toHaveBeenCalledWith( | |
| { fileId: 'f1', mimeType: 'text/markdown' }, | |
| { responseType: 'stream' } | |
| ); | |
| // files.get called for metadata only, NOT with alt: 'media' | |
| const getCalls = filesGet.mock.calls; | |
| expect(getCalls).toHaveLength(1); | |
| expect(getCalls[0][0]).not.toHaveProperty('alt'); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.exportedAs).toBe('text/markdown'); | |
| expect(parsed.originalMimeType).toBe('application/vnd.google-apps.document'); | |
| }); | |
| it('should use custom exportMimeType instead of default', async () => { | |
| const { filesExport } = createMockDrive({ | |
| name: 'My Doc', | |
| mimeType: 'application/vnd.google-apps.document', | |
| }); | |
| const result = await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/doc.pdf', exportMimeType: 'application/pdf' }, | |
| { log: mockLog } | |
| ); | |
| expect(filesExport).toHaveBeenCalledWith( | |
| { fileId: 'f1', mimeType: 'application/pdf' }, | |
| expect.anything() | |
| ); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.exportedAs).toBe('application/pdf'); | |
| }); | |
| }); | |
| describe('text extraction', () => { | |
| it('should return exact text content for text-based files', async () => { | |
| mockReadFileSync.mockReturnValue('col1,col2\na,b\n'); | |
| createMockDrive({ name: 'data.csv', mimeType: 'text/csv' }); | |
| const result = await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/data.csv' }, | |
| { log: mockLog } | |
| ); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.textContent).toBe('col1,col2\na,b\n'); | |
| }); | |
| it('should NOT include textContent for binary files', async () => { | |
| createMockDrive({ name: 'photo.png', mimeType: 'image/png' }); | |
| const result = await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/photo.png' }, | |
| { log: mockLog } | |
| ); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.textContent).toBeUndefined(); | |
| }); | |
| it('should truncate text content to 50000 characters', async () => { | |
| const bigText = 'x'.repeat(80_000); | |
| mockReadFileSync.mockReturnValue(bigText); | |
| createMockDrive({ name: 'big.txt', mimeType: 'text/plain' }); | |
| const result = await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/big.txt' }, | |
| { log: mockLog } | |
| ); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.textContent.length).toBe(50_000); | |
| }); | |
| it('should extract text from blob files with text MIME types', async () => { | |
| mockReadFileSync.mockReturnValue('hello world'); | |
| const { filesGet, filesExport } = createMockDrive({ | |
| name: 'notes.txt', | |
| mimeType: 'text/plain', | |
| }); | |
| const result = await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/notes.txt' }, | |
| { log: mockLog } | |
| ); | |
| // Downloaded via files.get (not export) | |
| expect(filesGet).toHaveBeenCalledWith( | |
| expect.objectContaining({ alt: 'media' }), | |
| expect.anything() | |
| ); | |
| expect(filesExport).not.toHaveBeenCalled(); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.textContent).toBe('hello world'); | |
| }); | |
| }); | |
| describe('optional savePath', () => { | |
| it('should default to cwd + filename for blob files', async () => { | |
| createMockDrive({ name: 'report.pdf', mimeType: 'application/pdf' }); | |
| await toolExecute({ fileId: 'f1' }, { log: mockLog }); | |
| expect(mockMkdirSync).toHaveBeenCalledWith(process.cwd(), { recursive: true }); | |
| expect(mockCreateWriteStream).toHaveBeenCalledWith(path.join(process.cwd(), 'report.pdf')); | |
| }); | |
| it('should default to cwd + name + .md for workspace docs', async () => { | |
| createMockDrive({ | |
| name: 'My Notes', | |
| mimeType: 'application/vnd.google-apps.document', | |
| }); | |
| const result = await toolExecute({ fileId: 'f1' }, { log: mockLog }); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.savedTo).toBe(path.join(process.cwd(), 'My Notes.md')); | |
| }); | |
| it('should use custom export extension when exportMimeType overrides default', async () => { | |
| createMockDrive({ | |
| name: 'Budget', | |
| mimeType: 'application/vnd.google-apps.spreadsheet', | |
| }); | |
| const result = await toolExecute( | |
| { fileId: 'f1', exportMimeType: 'application/pdf' }, | |
| { log: mockLog } | |
| ); | |
| const parsed = JSON.parse(result); | |
| expect(parsed.savedTo).toBe(path.join(process.cwd(), 'Budget.pdf')); | |
| }); | |
| }); | |
| describe('parent directory creation', () => { | |
| it('should call mkdirSync with exact dirname and recursive: true', async () => { | |
| createMockDrive(); | |
| await toolExecute( | |
| { fileId: 'f1', savePath: './downloads/sub/dir/file.pdf' }, | |
| { log: mockLog } | |
| ); | |
| expect(mockMkdirSync).toHaveBeenCalledWith( | |
| path.dirname(path.resolve('./downloads/sub/dir/file.pdf')), | |
| { recursive: true } | |
| ); | |
| }); | |
| }); | |
| describe('error cleanup', () => { | |
| it('should delete partial file and throw UserError on pipeline failure', async () => { | |
| createMockDrive(); | |
| mockPipeline.mockRejectedValue(new Error('network timeout')); | |
| await expect( | |
| toolExecute({ fileId: 'f1', savePath: './downloads/partial.pdf' }, { log: mockLog }) | |
| ).rejects.toThrow(UserError); | |
| await expect( | |
| toolExecute({ fileId: 'f1', savePath: './downloads/partial.pdf' }, { log: mockLog }) | |
| ).rejects.toThrow(/network timeout/); | |
| expect(mockUnlinkSync).toHaveBeenCalledWith(path.resolve('./downloads/partial.pdf')); | |
| }); | |
| }); | |
| describe('unsupported workspace type', () => { | |
| it('should throw UserError for types without export defaults', async () => { | |
| createMockDrive({ | |
| name: 'My Form', | |
| mimeType: 'application/vnd.google-apps.form', | |
| }); | |
| await expect( | |
| toolExecute({ fileId: 'f1', savePath: './downloads/form' }, { log: mockLog }) | |
| ).rejects.toThrow(UserError); | |
| await expect( | |
| toolExecute({ fileId: 'f1', savePath: './downloads/form' }, { log: mockLog }) | |
| ).rejects.toThrow(/form/); | |
| }); | |
| }); | |
| }); | |