Skip to content
123 changes: 123 additions & 0 deletions workspace-server/src/__tests__/services/DocsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('DocsService', () => {
let docsService: DocsService;
let mockAuthManager: jest.Mocked<AuthManager>;
let mockDocsAPI: any;
let mockDriveAPI: any;

beforeEach(() => {
// Clear all mocks before each test
Expand All @@ -43,8 +44,17 @@ describe('DocsService', () => {
},
};

// Create mock Drive API (used by the Markdown import tools)
mockDriveAPI = {
files: {
create: jest.fn(),
update: jest.fn(),
},
};

// Mock the google constructors
(google.docs as jest.Mock) = jest.fn().mockReturnValue(mockDocsAPI);
(google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI);

// Create DocsService instance
docsService = new DocsService(mockAuthManager);
Expand All @@ -59,6 +69,119 @@ describe('DocsService', () => {
jest.restoreAllMocks();
});

describe('createFromMarkdown', () => {
it('creates a Google Doc from markdown via Drive conversion', async () => {
mockDriveAPI.files.create.mockResolvedValue({
data: {
id: 'new-doc-id',
name: 'My Doc',
webViewLink: 'https://docs.google.com/document/d/new-doc-id/edit',
},
});

const result = await docsService.createFromMarkdown({
markdown: '# Title\n\n- a\n- b',
name: 'My Doc',
parentId: 'folder-1',
});

expect(mockDriveAPI.files.create).toHaveBeenCalledTimes(1);
const arg = mockDriveAPI.files.create.mock.calls[0][0];
// Destination mimeType must be a Google Doc to trigger conversion...
expect(arg.requestBody.mimeType).toBe(
'application/vnd.google-apps.document',
);
expect(arg.requestBody.name).toBe('My Doc');
expect(arg.requestBody.parents).toEqual(['folder-1']);
// ...and the source media must be text/markdown.
expect(arg.media.mimeType).toBe('text/markdown');

const payload = JSON.parse(result.content[0].text);
expect(payload).toMatchObject({
documentId: 'new-doc-id',
title: 'My Doc',
webViewLink: 'https://docs.google.com/document/d/new-doc-id/edit',
});
expect(result.isError).toBeUndefined();
});

it('omits parents when parentId is not provided', async () => {
mockDriveAPI.files.create.mockResolvedValue({ data: { id: 'x' } });

await docsService.createFromMarkdown({ markdown: '# Hi', name: 'Doc' });

const arg = mockDriveAPI.files.create.mock.calls[0][0];
expect(arg.requestBody.parents).toBeUndefined();
});

it('returns an error result when the Drive API fails', async () => {
mockDriveAPI.files.create.mockRejectedValue(new Error('boom'));

const result = await docsService.createFromMarkdown({
markdown: '# Hi',
name: 'Doc',
});

expect(result.isError).toBe(true);
expect(JSON.parse(result.content[0].text).error).toBe('boom');
});
});

describe('updateFromMarkdown', () => {
it('replaces an existing doc content in place, keeping the same id', async () => {
mockDriveAPI.files.update.mockResolvedValue({
data: {
id: 'doc-123',
name: 'Doc',
webViewLink: 'https://docs.google.com/document/d/doc-123/edit',
modifiedTime: '2026-07-01T00:00:00Z',
},
});

const result = await docsService.updateFromMarkdown({
documentId: 'doc-123',
markdown: '# Updated',
});

expect(mockDriveAPI.files.update).toHaveBeenCalledTimes(1);
const arg = mockDriveAPI.files.update.mock.calls[0][0];
expect(arg.fileId).toBe('doc-123');
expect(arg.media.mimeType).toBe('text/markdown');

const payload = JSON.parse(result.content[0].text);
expect(payload).toMatchObject({
documentId: 'doc-123',
modifiedTime: '2026-07-01T00:00:00Z',
});
expect(result.isError).toBeUndefined();
});

it('extracts the doc id from a full Docs URL', async () => {
mockDriveAPI.files.update.mockResolvedValue({ data: { id: 'doc-123' } });

await docsService.updateFromMarkdown({
documentId:
'https://docs.google.com/document/d/doc-123/edit?tab=t.0',
markdown: '# Updated',
});

const arg = mockDriveAPI.files.update.mock.calls[0][0];
expect(arg.fileId).toBe('doc-123');
});

it('returns an error result when the Drive API fails', async () => {
mockDriveAPI.files.update.mockRejectedValue(new Error('nope'));

const result = await docsService.updateFromMarkdown({
documentId: 'doc-123',
markdown: '# x',
});

expect(result.isError).toBe(true);
expect(JSON.parse(result.content[0].text).error).toBe('nope');
});
});

describe('create', () => {
it('should create a blank document', async () => {
const mockDoc = {
Expand Down
173 changes: 173 additions & 0 deletions workspace-server/src/__tests__/services/DriveService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jest.mock('node:fs', () => {
existsSync: jest.fn(),
writeFileSync: jest.fn(),
mkdirSync: jest.fn(),
createReadStream: jest.fn(),
};
});
jest.mock('node:path', () => {
Expand Down Expand Up @@ -76,6 +77,11 @@ describe('DriveService', () => {
comments: {
list: jest.fn(),
},
permissions: {
create: jest.fn(),
list: jest.fn(),
delete: jest.fn(),
},
};

// Mock the google.drive constructor
Expand Down Expand Up @@ -1439,4 +1445,171 @@ describe('DriveService', () => {
);
});
});

describe('uploadFile', () => {
it('should error (isError) when the local file does not exist', async () => {
(fs.existsSync as jest.Mock).mockReturnValue(false);

const result = await driveService.uploadFile({
localPath: '/does/not/exist.png',
});
const response = JSON.parse(result.content[0].text);

expect('isError' in result && result.isError).toBe(true);
expect(response.error).toContain('File not found');
expect(mockDriveAPI.files.create).not.toHaveBeenCalled();
});

it('should upload a private file and return its id', async () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
mockDriveAPI.files.create.mockResolvedValue({
data: { id: 'uploaded-id', name: 'pic.png', webViewLink: 'http://x' },
});

const result = await driveService.uploadFile({
localPath: '/tmp/pic.png',
mimeType: 'image/png',
});
const response = JSON.parse(result.content[0].text);

expect(response.id).toBe('uploaded-id');
// No sharing is granted on upload.
expect(mockDriveAPI.permissions.create).not.toHaveBeenCalled();
});

it('should convert to a native Google type when convertToGoogleType is set', async () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
mockDriveAPI.files.create.mockResolvedValue({
data: {
id: 'doc-id',
name: 'spike-stage',
webViewLink: 'http://x',
mimeType: 'application/vnd.google-apps.document',
},
});

const result = await driveService.uploadFile({
localPath: '/tmp/spike.docx',
name: 'spike-stage',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
convertToGoogleType: 'application/vnd.google-apps.document',
});
const response = JSON.parse(result.content[0].text);

// Destination (native) type goes on the file metadata; source Office
// type stays on the media body so Drive imports+converts the upload.
expect(mockDriveAPI.files.create).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
name: 'spike-stage',
mimeType: 'application/vnd.google-apps.document',
}),
media: expect.objectContaining({
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}),
}),
);
expect(response.id).toBe('doc-id');
expect(response.mimeType).toBe('application/vnd.google-apps.document');
});
});

describe('addPublicAccess', () => {
it('should grant anyone:reader and return an lh3 image URL', async () => {
mockDriveAPI.permissions.create.mockResolvedValue({
data: { id: 'perm-1' },
});

const result = await driveService.addPublicAccess({ fileId: 'file123' });
const response = JSON.parse(result.content[0].text);

expect(mockDriveAPI.permissions.create).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: { role: 'reader', type: 'anyone' },
}),
);
expect(response.imageUrl).toBe(
'https://lh3.googleusercontent.com/d/file123',
);
});

it('should surface a structured hint when org policy blocks sharing', async () => {
mockDriveAPI.permissions.create.mockRejectedValue({
message: 'Insufficient permissions',
errors: [{ reason: 'publishOutNotPermitted' }],
});

const result = await driveService.addPublicAccess({ fileId: 'file123' });
const response = JSON.parse(result.content[0].text);

expect('isError' in result && result.isError).toBe(true);
expect(response.reason).toBe('publishOutNotPermitted');
expect(response.hint).toContain('publicly reachable URL');
});
});

describe('removePublicAccess', () => {
it('should revoke only anyone-type permissions', async () => {
mockDriveAPI.permissions.list.mockResolvedValue({
data: {
permissions: [
{ id: 'p-anyone', type: 'anyone', role: 'reader' },
{ id: 'p-user', type: 'user', role: 'writer' },
],
},
});
mockDriveAPI.permissions.delete.mockResolvedValue({});

const result = await driveService.removePublicAccess({
fileId: 'file123',
});
const response = JSON.parse(result.content[0].text);

expect(mockDriveAPI.permissions.delete).toHaveBeenCalledTimes(1);
expect(mockDriveAPI.permissions.delete).toHaveBeenCalledWith(
expect.objectContaining({ permissionId: 'p-anyone' }),
);
expect(response.removed).toEqual(['p-anyone']);
expect(response.failed).toEqual([]);
});

it('should report partial failures without throwing', async () => {
mockDriveAPI.permissions.list.mockResolvedValue({
data: {
permissions: [
{ id: 'p-1', type: 'anyone' },
{ id: 'p-2', type: 'anyone' },
],
},
});
mockDriveAPI.permissions.delete
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('revoked auth'));

const result = await driveService.removePublicAccess({
fileId: 'file123',
});
const response = JSON.parse(result.content[0].text);

expect(response.removed).toEqual(['p-1']);
expect(response.failed).toHaveLength(1);
expect(response.failed[0].permissionId).toBe('p-2');
});

it('should be a no-op when there are no public permissions', async () => {
mockDriveAPI.permissions.list.mockResolvedValue({
data: { permissions: [{ id: 'p-user', type: 'user' }] },
});

const result = await driveService.removePublicAccess({
fileId: 'file123',
});
const response = JSON.parse(result.content[0].text);

expect(mockDriveAPI.permissions.delete).not.toHaveBeenCalled();
expect(response.removed).toEqual([]);
});
});
});
Loading
Loading