0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
checkGoogleSheetAccess,
|
|
fetchCsvFromGoogleSheetAuthenticated,
|
|
fetchCsvFromGoogleSheetUnauthenticated,
|
|
writeCsvToGoogleSheet,
|
|
} from '../src/googleSheets';
|
|
import logger from '../src/logger';
|
|
import { fetchWithProxy } from '../src/util/fetch/index';
|
|
import { createMockResponse } from './util/utils';
|
|
import type { Mock } from 'vitest';
|
|
|
|
import type { CsvRow } from '../src/types/index';
|
|
|
|
interface MockSpreadsheets {
|
|
get: Mock;
|
|
values: {
|
|
get: Mock;
|
|
update: Mock;
|
|
};
|
|
batchUpdate: Mock;
|
|
}
|
|
|
|
vi.mock('../src/util/fetch/index', () => ({
|
|
fetchWithProxy: vi.fn(),
|
|
}));
|
|
|
|
const mockSpreadsheetsApi = {
|
|
get: vi.fn(),
|
|
values: {
|
|
get: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
batchUpdate: vi.fn(),
|
|
};
|
|
|
|
// Update mock setup for better auth handling
|
|
const mockAuthClient = {
|
|
getClient: vi.fn().mockResolvedValue({}),
|
|
};
|
|
|
|
const mockGoogleAuth = vi.fn(function GoogleAuthMock() {
|
|
return mockAuthClient;
|
|
});
|
|
|
|
// Mock Google Sheets API
|
|
vi.mock('@googleapis/sheets', () => {
|
|
return {
|
|
sheets: vi.fn(() => ({
|
|
spreadsheets: mockSpreadsheetsApi,
|
|
})),
|
|
auth: {
|
|
GoogleAuth: mockGoogleAuth,
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('Google Sheets Integration', () => {
|
|
const TEST_SHEET_URL = 'https://docs.google.com/spreadsheets/d/1234567890/edit';
|
|
const TEST_SHEET_URL_WITH_GID =
|
|
'https://docs.google.com/spreadsheets/d/1234567890/edit?gid=98765';
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.spyOn(logger, 'error').mockImplementation(() => {});
|
|
vi.spyOn(logger, 'info').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('checkGoogleSheetAccess', () => {
|
|
it('should return public:true for accessible sheets', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(createMockResponse({ status: 200 }));
|
|
|
|
const result = await checkGoogleSheetAccess(TEST_SHEET_URL);
|
|
expect(result).toEqual({ public: true, status: 200 });
|
|
expect(fetchWithProxy).toHaveBeenCalledWith(TEST_SHEET_URL);
|
|
});
|
|
|
|
it('should return public:false for inaccessible sheets', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(createMockResponse({ status: 403 }));
|
|
|
|
const result = await checkGoogleSheetAccess(TEST_SHEET_URL);
|
|
expect(result).toEqual({ public: false, status: 403 });
|
|
expect(fetchWithProxy).toHaveBeenCalledWith(TEST_SHEET_URL);
|
|
});
|
|
|
|
it('should handle network errors gracefully', async () => {
|
|
vi.mocked(fetchWithProxy).mockRejectedValue(new Error('Network error'));
|
|
|
|
const result = await checkGoogleSheetAccess(TEST_SHEET_URL);
|
|
expect(result).toEqual({ public: false });
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
`Error checking sheet access: ${new Error('Network error')}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('fetchCsvFromGoogleSheetUnauthenticated', () => {
|
|
it('should fetch and parse CSV data correctly', async () => {
|
|
const mockCsvData = 'header1,header2\nvalue1,value2';
|
|
const expectedUrl = `${TEST_SHEET_URL.replace(/\/edit.*$/, '/export')}?format=csv`;
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(
|
|
createMockResponse({
|
|
text: () => Promise.resolve(mockCsvData),
|
|
}),
|
|
);
|
|
|
|
const result = await fetchCsvFromGoogleSheetUnauthenticated(TEST_SHEET_URL);
|
|
expect(result).toEqual([{ header1: 'value1', header2: 'value2' }]);
|
|
expect(fetchWithProxy).toHaveBeenCalledWith(expectedUrl);
|
|
});
|
|
|
|
it('should handle gid parameter correctly', async () => {
|
|
const mockCsvData = 'header1,header2\nvalue1,value2';
|
|
const baseUrl = TEST_SHEET_URL_WITH_GID.replace(/\/edit.*$/, '/export');
|
|
const expectedUrl = `${baseUrl}?format=csv&gid=98765`;
|
|
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(
|
|
createMockResponse({
|
|
text: () => Promise.resolve(mockCsvData),
|
|
}),
|
|
);
|
|
|
|
await fetchCsvFromGoogleSheetUnauthenticated(TEST_SHEET_URL_WITH_GID);
|
|
expect(fetchWithProxy).toHaveBeenCalledWith(expectedUrl);
|
|
});
|
|
|
|
it('should throw error on non-200 response', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValue(createMockResponse({ status: 403 }));
|
|
|
|
await expect(fetchCsvFromGoogleSheetUnauthenticated(TEST_SHEET_URL)).rejects.toThrow(
|
|
'Failed to fetch CSV from Google Sheets URL',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('fetchCsvFromGoogleSheetAuthenticated', () => {
|
|
const spreadsheets = mockSpreadsheetsApi as MockSpreadsheets;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Set up default sheet response
|
|
spreadsheets.get.mockResolvedValue({
|
|
data: {
|
|
sheets: [
|
|
{
|
|
properties: {
|
|
sheetId: 0,
|
|
title: 'Sheet1',
|
|
},
|
|
},
|
|
{
|
|
properties: {
|
|
sheetId: 98765,
|
|
title: 'TestSheet',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should fetch and parse authenticated sheet data', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
values: [
|
|
['header1', 'header2', 'header3'],
|
|
['value1', 'value2'],
|
|
],
|
|
},
|
|
};
|
|
spreadsheets.values.get.mockResolvedValue(mockResponse);
|
|
|
|
const result = await fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL);
|
|
expect(result).toEqual([{ header1: 'value1', header2: 'value2', header3: '' }]);
|
|
expect(spreadsheets.get).toHaveBeenCalledWith({
|
|
spreadsheetId: '1234567890',
|
|
auth: mockAuthClient,
|
|
});
|
|
expect(spreadsheets.values.get).toHaveBeenCalledWith({
|
|
spreadsheetId: '1234567890',
|
|
range: 'Sheet1',
|
|
auth: mockAuthClient,
|
|
});
|
|
});
|
|
|
|
it('should handle gid parameter correctly', async () => {
|
|
spreadsheets.values.get.mockResolvedValue({
|
|
data: {
|
|
values: [['header'], ['value']],
|
|
},
|
|
});
|
|
|
|
await fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL_WITH_GID);
|
|
expect(spreadsheets.get).toHaveBeenCalledWith({
|
|
spreadsheetId: '1234567890',
|
|
auth: mockAuthClient,
|
|
});
|
|
expect(spreadsheets.values.get).toHaveBeenCalledWith({
|
|
spreadsheetId: '1234567890',
|
|
range: 'TestSheet',
|
|
auth: mockAuthClient,
|
|
});
|
|
});
|
|
|
|
it('should throw error for invalid sheet URL', async () => {
|
|
await expect(fetchCsvFromGoogleSheetAuthenticated('invalid-url')).rejects.toThrow(
|
|
'Invalid Google Sheets URL',
|
|
);
|
|
});
|
|
|
|
it('should throw error when no sheets found', async () => {
|
|
spreadsheets.get.mockResolvedValue({
|
|
data: {
|
|
sheets: [],
|
|
},
|
|
});
|
|
|
|
await expect(fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL)).rejects.toThrow(
|
|
'No sheets found in spreadsheet',
|
|
);
|
|
});
|
|
|
|
it('should throw error when sheet with gid not found', async () => {
|
|
await expect(
|
|
fetchCsvFromGoogleSheetAuthenticated(
|
|
'https://docs.google.com/spreadsheets/d/1234567890/edit?gid=99999',
|
|
),
|
|
).rejects.toThrow('Sheet not found for gid: 99999');
|
|
});
|
|
});
|
|
|
|
describe('Range behavior and backwards compatibility', () => {
|
|
const spreadsheets = mockSpreadsheetsApi as MockSpreadsheets;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Set up default sheet response
|
|
spreadsheets.get.mockResolvedValue({
|
|
data: {
|
|
sheets: [
|
|
{
|
|
properties: {
|
|
sheetId: 0,
|
|
title: 'Sheet1',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should retrieve all data when using sheet name only (no range specified)', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
values: [
|
|
['header1', 'header2', 'header3'],
|
|
['value1', 'value2', 'value3'],
|
|
['value4', 'value5', 'value6'],
|
|
],
|
|
},
|
|
};
|
|
|
|
spreadsheets.values.get.mockResolvedValue(mockResponse);
|
|
|
|
const result = await fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL);
|
|
|
|
// Verify the API was called with just the sheet name
|
|
expect(spreadsheets.values.get).toHaveBeenCalledWith({
|
|
spreadsheetId: '1234567890',
|
|
range: 'Sheet1',
|
|
auth: mockAuthClient,
|
|
});
|
|
|
|
// Verify all data was returned
|
|
expect(result).toEqual([
|
|
{ header1: 'value1', header2: 'value2', header3: 'value3' },
|
|
{ header1: 'value4', header2: 'value5', header3: 'value6' },
|
|
]);
|
|
});
|
|
|
|
it('should handle empty cells correctly with sheet name only', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
values: [
|
|
['header1', 'header2', 'header3', 'header4'],
|
|
['value1', '', 'value3'], // Missing value2 and header4
|
|
['', 'value5', '', 'value7'], // Missing header1 and header3
|
|
],
|
|
},
|
|
};
|
|
|
|
spreadsheets.values.get.mockResolvedValue(mockResponse);
|
|
|
|
const result = await fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL);
|
|
|
|
expect(result).toEqual([
|
|
{ header1: 'value1', header2: '', header3: 'value3', header4: '' },
|
|
{ header1: '', header2: 'value5', header3: '', header4: 'value7' },
|
|
]);
|
|
});
|
|
|
|
it('should handle sheets with many columns beyond Z', async () => {
|
|
// Create headers for columns A through AZ (52 columns)
|
|
const headers = [];
|
|
for (let i = 0; i < 52; i++) {
|
|
headers.push(`header${i + 1}`);
|
|
}
|
|
|
|
const row1 = headers.map((_, i) => `value${i + 1}`);
|
|
|
|
const mockResponse = {
|
|
data: {
|
|
values: [headers, row1],
|
|
},
|
|
};
|
|
|
|
spreadsheets.values.get.mockResolvedValue(mockResponse);
|
|
|
|
const result = await fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL);
|
|
|
|
// Verify all 52 columns were retrieved
|
|
expect(Object.keys(result[0])).toHaveLength(52);
|
|
expect(result[0].header52).toBe('value52');
|
|
});
|
|
|
|
it('should handle trailing empty rows and columns correctly', async () => {
|
|
// According to Google Sheets API docs, trailing empty rows and columns are omitted
|
|
const mockResponse = {
|
|
data: {
|
|
values: [
|
|
['header1', 'header2', 'header3'],
|
|
['value1', 'value2', ''], // Trailing empty cell
|
|
['value4', '', ''], // Two trailing empty cells
|
|
// Trailing empty rows are not included in the response
|
|
],
|
|
},
|
|
};
|
|
|
|
spreadsheets.values.get.mockResolvedValue(mockResponse);
|
|
|
|
const result = await fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL);
|
|
|
|
expect(result).toEqual([
|
|
{ header1: 'value1', header2: 'value2', header3: '' },
|
|
{ header1: 'value4', header2: '', header3: '' },
|
|
]);
|
|
});
|
|
|
|
it('should handle very wide sheets efficiently', async () => {
|
|
// Create a sheet with 100 columns
|
|
const headers = Array.from({ length: 100 }, (_, i) => `Col${i + 1}`);
|
|
const row1 = Array.from({ length: 100 }, (_, i) => `Val${i + 1}`);
|
|
const testData = [headers, row1];
|
|
|
|
spreadsheets.get.mockResolvedValue({
|
|
data: {
|
|
sheets: [
|
|
{
|
|
properties: {
|
|
sheetId: 0,
|
|
title: 'WideSheet',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
spreadsheets.values.get.mockResolvedValue({
|
|
data: { values: testData },
|
|
});
|
|
|
|
const result = await fetchCsvFromGoogleSheetAuthenticated(TEST_SHEET_URL);
|
|
|
|
// Verify the API was called with just the sheet name (not a huge range)
|
|
expect(spreadsheets.values.get).toHaveBeenCalledWith({
|
|
spreadsheetId: '1234567890',
|
|
range: 'WideSheet',
|
|
auth: mockAuthClient,
|
|
});
|
|
|
|
// Verify all 100 columns were retrieved
|
|
expect(Object.keys(result[0])).toHaveLength(100);
|
|
expect(result[0].Col100).toBe('Val100');
|
|
});
|
|
|
|
it('should calculate correct column letters for write operations', async () => {
|
|
// Test the column letter calculation for various sizes
|
|
const testCases = [
|
|
{ cols: 1, expected: 'A' },
|
|
{ cols: 26, expected: 'Z' },
|
|
{ cols: 27, expected: 'AA' },
|
|
{ cols: 52, expected: 'AZ' },
|
|
{ cols: 53, expected: 'BA' },
|
|
{ cols: 702, expected: 'ZZ' },
|
|
{ cols: 703, expected: 'AAA' },
|
|
];
|
|
|
|
for (const { cols, expected } of testCases) {
|
|
vi.clearAllMocks(); // Clear mocks between iterations
|
|
|
|
const headers = Array.from({ length: cols }, (_, i) => `col${i + 1}`);
|
|
const mockRows: CsvRow[] = [
|
|
headers.reduce((acc, header) => ({ ...acc, [header]: 'value' }), {}),
|
|
];
|
|
|
|
spreadsheets.values.update.mockResolvedValue({});
|
|
spreadsheets.batchUpdate.mockResolvedValue({});
|
|
|
|
await writeCsvToGoogleSheet(mockRows, TEST_SHEET_URL);
|
|
|
|
const updateCall = spreadsheets.values.update.mock.calls[0][0];
|
|
const range = updateCall.range;
|
|
const endColumn = range.match(/!A1:([A-Z]+)\d+/)?.[1];
|
|
|
|
expect(endColumn).toBe(expected);
|
|
}
|
|
});
|
|
});
|
|
});
|