Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions workspace-server/src/__tests__/services/SheetsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@ describe('SheetsService', () => {
mockSheetsAPI = {
spreadsheets: {
get: jest.fn(),
create: jest.fn(),
batchUpdate: jest.fn(),
values: {
get: jest.fn(),
update: jest.fn(),
append: jest.fn(),
clear: jest.fn(),
},
},
};
Expand Down Expand Up @@ -370,4 +375,346 @@ describe('SheetsService', () => {
expect(response.error).toBe('Metadata Error');
});
});

describe('updateRange', () => {
it('should write values to a specific range', async () => {
const mockResponse = {
data: {
updatedRange: 'Sheet1!A1:B2',
updatedRows: 2,
updatedColumns: 2,
updatedCells: 4,
},
};

mockSheetsAPI.spreadsheets.values.update.mockResolvedValue(mockResponse);

const result = await sheetsService.updateRange({
spreadsheetId: 'test-id',
range: 'Sheet1!A1:B2',
values: [
['A1', 'B1'],
['A2', 'B2'],
],
});

expect(mockSheetsAPI.spreadsheets.values.update).toHaveBeenCalledWith({
spreadsheetId: 'test-id',
range: 'Sheet1!A1:B2',
valueInputOption: 'USER_ENTERED',
requestBody: {
values: [
['A1', 'B1'],
['A2', 'B2'],
],
},
});

const response = JSON.parse(result.content[0].text);
expect(response.updatedRange).toBe('Sheet1!A1:B2');
expect(response.updatedRows).toBe(2);
expect(response.updatedColumns).toBe(2);
expect(response.updatedCells).toBe(4);
});

it('should use RAW valueInputOption when specified', async () => {
const mockResponse = {
data: {
updatedRange: 'Sheet1!A1:A1',
updatedRows: 1,
updatedColumns: 1,
updatedCells: 1,
},
};

mockSheetsAPI.spreadsheets.values.update.mockResolvedValue(mockResponse);

await sheetsService.updateRange({
spreadsheetId: 'test-id',
range: 'Sheet1!A1',
values: [['=SUM(B1:B10)']],
valueInputOption: 'RAW',
});

expect(mockSheetsAPI.spreadsheets.values.update).toHaveBeenCalledWith(
expect.objectContaining({
valueInputOption: 'RAW',
}),
);
});

it('should handle errors gracefully', async () => {
mockSheetsAPI.spreadsheets.values.update.mockRejectedValue(
new Error('Update Error'),
);

const result = await sheetsService.updateRange({
spreadsheetId: 'error-id',
range: 'Sheet1!A1',
values: [['test']],
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('Update Error');
});
});

describe('appendRange', () => {
it('should append rows to a sheet', async () => {
const mockResponse = {
data: {
updates: {
updatedRange: 'Sheet1!A4:B5',
updatedRows: 2,
updatedColumns: 2,
updatedCells: 4,
},
},
};

mockSheetsAPI.spreadsheets.values.append.mockResolvedValue(mockResponse);

const result = await sheetsService.appendRange({
spreadsheetId: 'test-id',
range: 'Sheet1!A:B',
values: [
['NewRow1', 'Data1'],
['NewRow2', 'Data2'],
],
});

expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith({
spreadsheetId: 'test-id',
range: 'Sheet1!A:B',
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: {
values: [
['NewRow1', 'Data1'],
['NewRow2', 'Data2'],
],
},
});

const response = JSON.parse(result.content[0].text);
expect(response.updates.updatedRange).toBe('Sheet1!A4:B5');
expect(response.updates.updatedRows).toBe(2);
});

it('should handle errors gracefully', async () => {
mockSheetsAPI.spreadsheets.values.append.mockRejectedValue(
new Error('Append Error'),
);

const result = await sheetsService.appendRange({
spreadsheetId: 'error-id',
range: 'Sheet1!A:B',
values: [['test']],
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('Append Error');
});
});

describe('clearRange', () => {
it('should clear values from a range', async () => {
const mockResponse = {
data: {
clearedRange: 'Sheet1!A1:D10',
},
};

mockSheetsAPI.spreadsheets.values.clear.mockResolvedValue(mockResponse);

const result = await sheetsService.clearRange({
spreadsheetId: 'test-id',
range: 'Sheet1!A1:D10',
});

expect(mockSheetsAPI.spreadsheets.values.clear).toHaveBeenCalledWith({
spreadsheetId: 'test-id',
range: 'Sheet1!A1:D10',
});

const response = JSON.parse(result.content[0].text);
expect(response.clearedRange).toBe('Sheet1!A1:D10');
});

it('should handle errors gracefully', async () => {
mockSheetsAPI.spreadsheets.values.clear.mockRejectedValue(
new Error('Clear Error'),
);

const result = await sheetsService.clearRange({
spreadsheetId: 'error-id',
range: 'Sheet1!A1:A1',
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('Clear Error');
});
});

describe('createSpreadsheet', () => {
it('should create a new spreadsheet', async () => {
const mockResponse = {
data: {
spreadsheetId: 'new-spreadsheet-id',
spreadsheetUrl:
'https://docs.google.com/spreadsheets/d/new-spreadsheet-id',
properties: { title: 'My New Sheet' },
sheets: [{ properties: { sheetId: 0, title: 'Sheet1' } }],
},
};

mockSheetsAPI.spreadsheets.create.mockResolvedValue(mockResponse);

const result = await sheetsService.createSpreadsheet({
title: 'My New Sheet',
});

expect(mockSheetsAPI.spreadsheets.create).toHaveBeenCalledWith({
requestBody: {
properties: { title: 'My New Sheet' },
sheets: undefined,
},
});

const response = JSON.parse(result.content[0].text);
expect(response.spreadsheetId).toBe('new-spreadsheet-id');
expect(response.title).toBe('My New Sheet');
});

it('should create a spreadsheet with custom sheet titles', async () => {
const mockResponse = {
data: {
spreadsheetId: 'new-id',
spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/new-id',
properties: { title: 'Budget' },
sheets: [
{ properties: { sheetId: 0, title: 'Summary' } },
{ properties: { sheetId: 1, title: 'Data' } },
],
},
};

mockSheetsAPI.spreadsheets.create.mockResolvedValue(mockResponse);

const result = await sheetsService.createSpreadsheet({
title: 'Budget',
sheetTitles: ['Summary', 'Data'],
});

expect(mockSheetsAPI.spreadsheets.create).toHaveBeenCalledWith({
requestBody: {
properties: { title: 'Budget' },
sheets: [
{ properties: { title: 'Summary' } },
{ properties: { title: 'Data' } },
],
},
});

const response = JSON.parse(result.content[0].text);
expect(response.sheets).toHaveLength(2);
expect(response.sheets[0].title).toBe('Summary');
});

it('should handle errors gracefully', async () => {
mockSheetsAPI.spreadsheets.create.mockRejectedValue(
new Error('Create Error'),
);

const result = await sheetsService.createSpreadsheet({
title: 'Error Sheet',
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('Create Error');
});
});

describe('addSheet', () => {
it('should add a new sheet to a spreadsheet', async () => {
const mockResponse = {
data: {
replies: [
{
addSheet: {
properties: { sheetId: 123, title: 'New Tab' },
},
},
],
},
};

mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue(mockResponse);

const result = await sheetsService.addSheet({
spreadsheetId: 'test-id',
title: 'New Tab',
});

expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({
spreadsheetId: 'test-id',
requestBody: {
requests: [{ addSheet: { properties: { title: 'New Tab' } } }],
},
});

const response = JSON.parse(result.content[0].text);
expect(response.sheetId).toBe(123);
expect(response.title).toBe('New Tab');
});

it('should handle errors gracefully', async () => {
mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue(
new Error('AddSheet Error'),
);

const result = await sheetsService.addSheet({
spreadsheetId: 'error-id',
title: 'Bad Tab',
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('AddSheet Error');
});
});

describe('deleteSheet', () => {
it('should delete a sheet from a spreadsheet', async () => {
mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue({ data: {} });

const result = await sheetsService.deleteSheet({
spreadsheetId: 'test-id',
sheetId: 456,
});

expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({
spreadsheetId: 'test-id',
requestBody: {
requests: [{ deleteSheet: { sheetId: 456 } }],
},
});

const response = JSON.parse(result.content[0].text);
expect(response.message).toBe('Successfully deleted sheet 456');
});

it('should handle errors gracefully', async () => {
mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue(
new Error('DeleteSheet Error'),
);

const result = await sheetsService.deleteSheet({
spreadsheetId: 'error-id',
sheetId: 999,
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toBe('DeleteSheet Error');
});
});
});
9 changes: 8 additions & 1 deletion workspace-server/src/features/feature-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,14 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [
service: 'sheets',
group: 'write',
scopes: scopes('spreadsheets'),
tools: [],
tools: [
'sheets.updateRange',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up on a collision: open PR #342 also populates this same sheets.write tools array — with sheets.appendRows, whereas this PR adds sheets.appendRange. So (a) whichever merges first will force a conflict on the other here, and (b) the two introduce overlapping append functionality under different names. This needs a maintainer decision to reconcile (pick one append tool / one naming) before either lands.

'sheets.appendRange',
'sheets.clearRange',
'sheets.createSpreadsheet',
'sheets.addSheet',
'sheets.deleteSheet',
],
defaultEnabled: false,
},

Expand Down
Loading
Loading