diff --git a/package.json b/package.json index ce60f45..d1f2989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-gsheets", - "version": "1.7.1", + "version": "1.7.2", "description": "Model Context Protocol (MCP) server for Google Sheets API integration", "author": "freema", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index 98c6789..f845007 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,9 @@ const toolHandlers = new Map Promise>([ ['sheets_get_formatting_compact', tools.handleGetFormattingCompact], ['sheets_get_data_validation', tools.handleGetDataValidation], ['sheets_get_basic_filter', tools.handleGetBasicFilter], + // Border and comparison tools + ['sheets_get_border_map', tools.handleGetBorderMap], + ['sheets_compare_ranges', tools.handleCompareRanges], ]); // All tools @@ -127,6 +130,9 @@ const allTools = [ tools.getFormattingCompactTool, tools.getDataValidationTool, tools.getBasicFilterTool, + // Border and comparison tools + tools.getBorderMapTool, + tools.compareRangesTool, ]; async function main() { diff --git a/src/tools/compare-ranges.ts b/src/tools/compare-ranges.ts new file mode 100644 index 0000000..5eb1c69 --- /dev/null +++ b/src/tools/compare-ranges.ts @@ -0,0 +1,264 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { sheets_v4 } from 'googleapis'; +import { getAuthenticatedClient } from '../utils/google-auth.js'; +import { handleError } from '../utils/error-handler.js'; +import { formatSuccessResponse } from '../utils/formatters.js'; +import { ToolResponse } from '../types/tools.js'; +import { columnToIndex, colIndexToLetter } from '../utils/range-helpers.js'; +import { extractSheetName } from '../utils/range-helpers.js'; +import { extractFormatFields } from '../utils/compact-format.js'; + +const inputSchema = z.object({ + spreadsheetId: z.string(), + rangeA: z.string().describe('First range with sheet prefix, e.g. "Sheet1!A6:Z6"'), + rangeB: z.string().describe('Second range with sheet prefix, e.g. "Sheet1!A7:Z7"'), + fields: z.array(z.string()).optional(), + useEffectiveFormat: z.boolean().optional().default(false), + mode: z + .enum(['row-by-row', 'full']) + .optional() + .default('row-by-row') + .describe( + '"row-by-row": compare each row in rangeA against corresponding row in rangeB. ' + + '"full": compare the entire formatting of rangeA against rangeB as blocks.' + ), +}); + +export const compareRangesTool: Tool = { + name: 'sheets_compare_ranges', + description: + 'Compare cell formatting between two ranges. ' + + 'Useful for verifying repeated patterns, e.g. "do all data rows 6–85 have identical formatting?" ' + + 'or "is row 10 formatted identically to the template row 5?". ' + + 'Returns a diff listing only the cells and properties that differ between the two ranges. ' + + 'When ranges have the same dimensions, cells are compared position-by-position. ' + + 'Use mode:"row-by-row" to compare row N of rangeA with row N of rangeB (default). ' + + 'Use fields to restrict comparison to specific format properties.', + inputSchema: { + type: 'object', + properties: { + spreadsheetId: { + type: 'string', + description: 'The ID of the spreadsheet (found in the URL after /d/)', + }, + rangeA: { + type: 'string', + description: 'First range with sheet prefix, e.g. "Sheet1!A6:Z6"', + }, + rangeB: { + type: 'string', + description: 'Second range with sheet prefix, e.g. "Sheet1!A7:Z7"', + }, + fields: { + type: 'array', + items: { type: 'string' }, + description: + 'Optional list of format property names to compare, e.g. ["backgroundColor", "textFormat"]. ' + + 'All format properties compared if omitted.', + }, + useEffectiveFormat: { + type: 'boolean', + description: + 'Default: false. Compare effectiveFormat (true) or userEnteredFormat (false). ' + + 'effectiveFormat includes conditional formatting overlays.', + }, + mode: { + type: 'string', + enum: ['row-by-row', 'full'], + description: + '"row-by-row" (default): compare row 1 of rangeA with row 1 of rangeB, etc. ' + + '"full": flatten both ranges and compare cell by cell in reading order.', + }, + }, + required: ['spreadsheetId', 'rangeA', 'rangeB'], + }, +}; + +function parseRangeParts(range: string): { + sheetName: string; + startCol: number; + startRow: number; + endCol: number; + endRow: number; +} { + if (!range.includes('!')) { + throw new Error(`Range must include sheet name prefix, e.g. "Sheet1!A1:F10". Got: "${range}"`); + } + const { sheetName, range: rangeOnly } = extractSheetName(range); + if (!sheetName) { + throw new Error(`Range must include sheet name prefix, e.g. "Sheet1!A1:F10". Got: "${range}"`); + } + + const match = rangeOnly.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i); + if (!match) { + throw new Error(`Invalid range format: "${rangeOnly}". Expected e.g. "A1:F10".`); + } + return { + sheetName, + startCol: columnToIndex(match[1]!.toUpperCase()), + startRow: parseInt(match[2]!) - 1, + endCol: columnToIndex(match[3]!.toUpperCase()) + 1, // exclusive + endRow: parseInt(match[4]!), // exclusive + }; +} + +function getFmtKey( + rowData: sheets_v4.Schema$RowData[], + rowOffset: number, + colOffset: number, + useEffectiveFormat: boolean, + fields?: string[] +): { key: string; fmt: Record } { + const row = rowData[rowOffset]; + const cell = row?.values?.[colOffset]; + const rawFmt = useEffectiveFormat ? cell?.effectiveFormat : cell?.userEnteredFormat; + if (!rawFmt) { + return { key: '{}', fmt: {} }; + } + const fmt = extractFormatFields(rawFmt, fields); + return { key: JSON.stringify(fmt), fmt }; +} + +interface CellDiff { + cellA: string; + cellB: string; + diffs: Record; +} + +function deepDiffProperties( + fmtA: Record, + fmtB: Record +): Record { + const allKeys = new Set([...Object.keys(fmtA), ...Object.keys(fmtB)]); + const diffs: Record = {}; + for (const key of allKeys) { + const aVal = fmtA[key]; + const bVal = fmtB[key]; + if (JSON.stringify(aVal) !== JSON.stringify(bVal)) { + diffs[key] = { a: aVal ?? null, b: bVal ?? null }; + } + } + return diffs; +} + +export async function handleCompareRanges(input: any): Promise { + try { + const { + spreadsheetId, + rangeA, + rangeB, + fields: formatFields, + useEffectiveFormat, + } = inputSchema.parse(input); + + const parsedA = parseRangeParts(rangeA); + const parsedB = parseRangeParts(rangeB); + + const numRowsA = parsedA.endRow - parsedA.startRow; + const numColsA = parsedA.endCol - parsedA.startCol; + const numRowsB = parsedB.endRow - parsedB.startRow; + const numColsB = parsedB.endCol - parsedB.startCol; + + if (numColsA !== numColsB) { + throw new Error( + `Ranges must have the same number of columns. rangeA has ${numColsA} columns, rangeB has ${numColsB}.` + ); + } + if (numRowsA !== numRowsB) { + throw new Error( + `Ranges must have the same number of rows. rangeA has ${numRowsA} rows, rangeB has ${numRowsB}.` + ); + } + + const formatField = useEffectiveFormat ? 'effectiveFormat' : 'userEnteredFormat'; + + const sheets = await getAuthenticatedClient(); + + const response = await sheets.spreadsheets.get({ + spreadsheetId, + ranges: [rangeA, rangeB], + includeGridData: true, + fields: + 'sheets.properties.title,' + + 'sheets.data.startRow,sheets.data.startColumn,' + + `sheets.data.rowData.values.${formatField}`, + }); + + // Find grid data for each range — responses come back per-sheet, per-range + // When both ranges are in the same sheet, they appear as two data entries in the same sheet + // When in different sheets, they appear in different sheet entries + const allSheets = response.data.sheets ?? []; + + function findGridData( + parsed: ReturnType, + rangeStr: string + ): sheets_v4.Schema$GridData { + for (const s of allSheets) { + if (s.properties?.title !== parsed.sheetName) { + continue; + } + for (const gd of s.data ?? []) { + const gdStartRow = gd.startRow ?? 0; + const gdStartCol = gd.startColumn ?? 0; + if (gdStartRow === parsed.startRow && gdStartCol === parsed.startCol) { + return gd; + } + } + } + throw new Error(`Could not locate grid data for range "${rangeStr}". Check the sheet name.`); + } + + const gdA = findGridData(parsedA, rangeA); + const gdB = findGridData(parsedB, rangeB); + + const rowDataA: sheets_v4.Schema$RowData[] = gdA.rowData ?? []; + const rowDataB: sheets_v4.Schema$RowData[] = gdB.rowData ?? []; + + const diffs: CellDiff[] = []; + let equalCells = 0; + + for (let r = 0; r < numRowsA; r++) { + for (let c = 0; c < numColsA; c++) { + const { fmt: fmtA } = getFmtKey(rowDataA, r, c, useEffectiveFormat, formatFields); + const { fmt: fmtB } = getFmtKey(rowDataB, r, c, useEffectiveFormat, formatFields); + + const propDiffs = deepDiffProperties(fmtA, fmtB); + if (Object.keys(propDiffs).length === 0) { + equalCells++; + continue; + } + diffs.push({ + cellA: `${colIndexToLetter(parsedA.startCol + c)}${parsedA.startRow + r + 1}`, + cellB: `${colIndexToLetter(parsedB.startCol + c)}${parsedB.startRow + r + 1}`, + diffs: propDiffs, + }); + } + } + + const totalCells = numRowsA * numColsA; + const identical = diffs.length === 0; + + return formatSuccessResponse( + { + rangeA, + rangeB, + dimensions: { rows: numRowsA, cols: numColsA, totalCells }, + formatType: formatField, + fieldsCompared: formatFields ?? 'all', + identical, + summary: identical + ? `All ${totalCells} cells have identical formatting.` + : `${diffs.length} of ${totalCells} cells differ. ${equalCells} cells are identical.`, + equalCells, + diffCount: diffs.length, + diffs, + }, + identical + ? `Ranges ${rangeA} and ${rangeB} have identical formatting` + : `Found ${diffs.length} formatting difference(s) between ${rangeA} and ${rangeB}` + ); + } catch (error) { + return handleError(error); + } +} diff --git a/src/tools/create-chart.ts b/src/tools/create-chart.ts index 0239215..fd3b7ee 100644 --- a/src/tools/create-chart.ts +++ b/src/tools/create-chart.ts @@ -274,30 +274,24 @@ export async function handleCreateChart(input: any): Promise { if (validatedInput.domainAxis?.title) { const axis: sheets_v4.Schema$BasicChartAxis = { position: 'BOTTOM_AXIS', + title: validatedInput.domainAxis.title, }; - if (validatedInput.domainAxis.title !== undefined) { - axis.title = validatedInput.domainAxis.title; - } axes.push(axis); } if (validatedInput.leftAxis?.title) { const axis: sheets_v4.Schema$BasicChartAxis = { position: 'LEFT_AXIS', + title: validatedInput.leftAxis.title, }; - if (validatedInput.leftAxis.title !== undefined) { - axis.title = validatedInput.leftAxis.title; - } axes.push(axis); } if (validatedInput.rightAxis?.title) { const axis: sheets_v4.Schema$BasicChartAxis = { position: 'RIGHT_AXIS', + title: validatedInput.rightAxis.title, }; - if (validatedInput.rightAxis.title !== undefined) { - axis.title = validatedInput.rightAxis.title; - } axes.push(axis); } @@ -315,6 +309,7 @@ export async function handleCreateChart(input: any): Promise { // First, we need to identify domain (usually first column) // For now, we'll assume domain is in the same sheet as first series const firstSeries = validatedInput.series[0]; + /* v8 ignore next 3 */ if (!firstSeries) { throw new Error('At least one series is required'); } @@ -402,6 +397,7 @@ export async function handleCreateChart(input: any): Promise { ) { // For pie charts, parse the first series range const firstSeries = validatedInput.series[0]; + /* v8 ignore next 3 */ if (!firstSeries) { throw new Error('At least one series is required for pie chart'); } diff --git a/src/tools/get-border-map.ts b/src/tools/get-border-map.ts new file mode 100644 index 0000000..beeff34 --- /dev/null +++ b/src/tools/get-border-map.ts @@ -0,0 +1,228 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { sheets_v4 } from 'googleapis'; +import { getAuthenticatedClient } from '../utils/google-auth.js'; +import { handleError } from '../utils/error-handler.js'; +import { formatSuccessResponse } from '../utils/formatters.js'; +import { ToolResponse } from '../types/tools.js'; +import { colIndexToLetter, columnToIndex } from '../utils/range-helpers.js'; +import { extractSheetName } from '../utils/range-helpers.js'; + +const inputSchema = z.object({ + spreadsheetId: z.string(), + range: z.string().describe('Range with sheet prefix, e.g. "Sheet1!A1:F10"'), + includeStyle: z.boolean().optional().default(false), +}); + +export const getBorderMapTool: Tool = { + name: 'sheets_get_border_map', + description: + 'Returns a visual tabular map of borders for a range. ' + + 'Instead of per-cell JSON with 4 separate border objects, returns compact grids showing ' + + 'which cells have top/bottom/left/right borders and their styles. ' + + 'Solves the ambiguity between "right border of cell N" vs "left border of cell N+1". ' + + 'Output: a horizontal-lines grid and a vertical-lines grid, each as a 2D array of line styles. ' + + 'Set includeStyle:true to include color and width details (larger output).', + inputSchema: { + type: 'object', + properties: { + spreadsheetId: { + type: 'string', + description: 'The ID of the spreadsheet (found in the URL after /d/)', + }, + range: { + type: 'string', + description: + 'Range with sheet prefix, e.g. "Sheet1!A1:F10". ' + + 'Sheet name is required to resolve the range correctly.', + }, + includeStyle: { + type: 'boolean', + description: + 'Default: false. When true, each border line includes color and width details. ' + + 'When false, only the style name (SOLID, DASHED, etc.) is shown — more compact.', + }, + }, + required: ['spreadsheetId', 'range'], + }, +}; + +/** Encode a single border into a compact string or full object */ +function encodeBorder( + border: sheets_v4.Schema$Border | null | undefined, + includeStyle: boolean +): any { + if (!border || border.style === 'NONE' || !border.style) { + return null; + } + if (!includeStyle) { + return border.style; + } + return { + style: border.style, + ...(border.colorStyle ? { colorStyle: border.colorStyle } : {}), + ...(border.color ? { color: border.color } : {}), + ...(border.width !== undefined ? { width: border.width } : {}), + }; +} + +export async function handleGetBorderMap(input: any): Promise { + try { + const { spreadsheetId, range, includeStyle } = inputSchema.parse(input); + + // Parse sheet name and range + if (!range.includes('!')) { + throw new Error('Range must include sheet name prefix, e.g. "Sheet1!A1:F10"'); + } + const { sheetName, range: rangeOnly } = extractSheetName(range); + if (!sheetName) { + throw new Error('Range must include sheet name prefix, e.g. "Sheet1!A1:F10"'); + } + + // Parse range bounds + const rangeMatch = rangeOnly.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i); + if (!rangeMatch) { + throw new Error(`Invalid range format: "${rangeOnly}". Expected e.g. "A1:F10".`); + } + const startCol = columnToIndex(rangeMatch[1]!.toUpperCase()); + const startRow = parseInt(rangeMatch[2]!) - 1; + const endCol = columnToIndex(rangeMatch[3]!.toUpperCase()) + 1; // exclusive + const endRow = parseInt(rangeMatch[4]!); // exclusive + + const numRows = endRow - startRow; + const numCols = endCol - startCol; + + const sheets = await getAuthenticatedClient(); + + const response = await sheets.spreadsheets.get({ + spreadsheetId, + ranges: [range], + includeGridData: true, + fields: + 'sheets.properties.title,' + + 'sheets.data.startRow,sheets.data.startColumn,' + + 'sheets.data.rowData.values.userEnteredFormat.borders', + }); + + const sheetData = (response.data.sheets ?? []).find( + (s: sheets_v4.Schema$Sheet) => s.properties?.title === sheetName + ); + if (!sheetData) { + const available = (response.data.sheets ?? []) + .map((s: sheets_v4.Schema$Sheet) => s.properties?.title) + .filter(Boolean) + .join(', '); + throw new Error(`Sheet "${sheetName}" not found. Available: ${available}`); + } + + const gridData = sheetData.data?.[0] ?? {}; + const rowData: sheets_v4.Schema$RowData[] = gridData.rowData ?? []; + + // Helper: get border for a specific cell + const getBorder = (rowOffset: number, colOffset: number): sheets_v4.Schema$Border[] => { + // Returns [top, bottom, left, right] for cell at (rowOffset, colOffset) within range + const row = rowData[rowOffset]; + const cell = row?.values?.[colOffset]; + const borders = cell?.userEnteredFormat?.borders; + return [ + borders?.top ?? null, + borders?.bottom ?? null, + borders?.left ?? null, + borders?.right ?? null, + ] as any; + }; + + // ── Horizontal lines grid (numRows+1 rows × numCols cols) ────────────────── + // horizontalLines[r][c] = border line ABOVE row r at col c + // r=0 → top of first row, r=numRows → bottom of last row + // + // Rule: line between row r-1 and row r is: + // bottom of cell (r-1, c) OR top of cell (r, c) + // (take whichever is non-null, prefer bottom of upper cell) + const horizontalLines: any[][] = []; + for (let r = 0; r <= numRows; r++) { + const row: any[] = []; + for (let c = 0; c < numCols; c++) { + let line: any = null; + if (r > 0) { + // bottom border of cell above + const [, bottom] = getBorder(r - 1, c); + if (bottom?.style && bottom.style !== 'NONE') { + line = encodeBorder(bottom, includeStyle); + } + } + if (r < numRows && line === null) { + // top border of current cell + const [top] = getBorder(r, c); + if (top?.style && top.style !== 'NONE') { + line = encodeBorder(top, includeStyle); + } + } + row.push(line); + } + horizontalLines.push(row); + } + + // ── Vertical lines grid (numRows rows × numCols+1 cols) ─────────────────── + // verticalLines[r][c] = border line to the LEFT of column c at row r + // c=0 → left of first col, c=numCols → right of last col + const verticalLines: any[][] = []; + for (let r = 0; r < numRows; r++) { + const row: any[] = []; + for (let c = 0; c <= numCols; c++) { + let line: any = null; + if (c < numCols) { + // left border of current cell + const [, , left] = getBorder(r, c); + if (left?.style && left.style !== 'NONE') { + line = encodeBorder(left, includeStyle); + } + } + if (c > 0 && line === null) { + // right border of cell to the left + const [, , , right] = getBorder(r, c - 1); + if (right?.style && right.style !== 'NONE') { + line = encodeBorder(right, includeStyle); + } + } + row.push(line); + } + verticalLines.push(row); + } + + // ── Column / row headers ─────────────────────────────────────────────────── + const colHeaders = Array.from({ length: numCols }, (_, i) => colIndexToLetter(startCol + i)); + const rowHeaders = Array.from({ length: numRows }, (_, i) => String(startRow + i + 1)); + + return formatSuccessResponse( + { + range, + sheetName, + dimensions: { rows: numRows, cols: numCols }, + colHeaders, + rowHeaders, + // horizontalLines: (numRows+1) × numCols — line above each cell row + horizontalLines: { + description: + 'horizontalLines[r][c] = style of horizontal border line above row r at column c. ' + + 'r=0 → top edge, r=numRows → bottom edge.', + rowCount: numRows + 1, + colCount: numCols, + data: horizontalLines, + }, + // verticalLines: numRows × (numCols+1) — line left of each cell column + verticalLines: { + description: + 'verticalLines[r][c] = style of vertical border line left of column c at row r. ' + + 'c=0 → left edge, c=numCols → right edge.', + rowCount: numRows, + colCount: numCols + 1, + data: verticalLines, + }, + }, + `Border map for ${range} (${numRows}×${numCols} cells)` + ); + } catch (error) { + return handleError(error); + } +} diff --git a/src/tools/get-conditional-formatting-data.ts b/src/tools/get-conditional-formatting-data.ts index 57f203d..d163d34 100644 --- a/src/tools/get-conditional-formatting-data.ts +++ b/src/tools/get-conditional-formatting-data.ts @@ -4,17 +4,22 @@ import { getAuthenticatedClient } from '../utils/google-auth.js'; import { handleError } from '../utils/error-handler.js'; import { formatSuccessResponse } from '../utils/formatters.js'; import { ToolResponse } from '../types/tools.js'; +import { normalizeConditionalFormatFormulas } from '../utils/formula-locale.js'; import { findSheetOrThrow } from '../utils/range-helpers.js'; const inputSchema = z.object({ spreadsheetId: z.string(), sheetName: z.string(), + normalizeFormulas: z.boolean().optional().default(true), }); export const getConditionalFormattingDataTool: Tool = { name: 'sheets_get_conditional_formatting', description: - 'Read conditional formatting rules and banded ranges (alternating row/column colors) for a sheet.', + 'Read conditional formatting rules and banded ranges (alternating row/column colors) for a sheet. ' + + 'CF formulas are normalized to English locale (semicolons → commas) by default. ' + + 'Each rule with a formula includes a "_formulaLocaleRaw" field with the original unmodified formula. ' + + 'Set normalizeFormulas:false to get raw formulas as returned by the API.', inputSchema: { type: 'object', properties: { @@ -26,6 +31,13 @@ export const getConditionalFormattingDataTool: Tool = { type: 'string', description: 'Name of the sheet (tab) to inspect', }, + normalizeFormulas: { + type: 'boolean', + description: + 'Default: true. Normalize formula separators to English locale (semicolons → commas). ' + + 'Each normalized rule includes "_formulaLocaleRaw" with the original formula. ' + + 'Set to false to get formulas exactly as returned by the Google Sheets API.', + }, }, required: ['spreadsheetId', 'sheetName'], }, @@ -33,7 +45,7 @@ export const getConditionalFormattingDataTool: Tool = { export async function handleGetConditionalFormattingData(input: any): Promise { try { - const { spreadsheetId, sheetName } = inputSchema.parse(input); + const { spreadsheetId, sheetName, normalizeFormulas } = inputSchema.parse(input); const sheets = await getAuthenticatedClient(); const response = await sheets.spreadsheets.get({ @@ -46,11 +58,17 @@ export async function handleGetConditionalFormattingData(input: any): Promise0;"yes";"no") + * English: =IF(A1>0,"yes","no") + * + * This function replaces semicolons used as argument separators with commas, + * while preserving semicolons inside quoted string literals. + * + * Returns { normalized, raw } where: + * - normalized: formula with commas as separators (canonical form) + * - raw: original formula unchanged + * - localeDetected: 'semicolon' | 'comma' | 'unknown' + */ +export function normalizeFormulaLocale(formula: string): { + normalized: string; + raw: string; + localeDetected: 'semicolon' | 'comma' | 'unknown'; +} { + if (!formula?.startsWith('=')) { + return { normalized: formula, raw: formula, localeDetected: 'unknown' }; + } + + let result = ''; + let inString = false; + let hasSemicolonSeparator = false; + + for (let i = 0; i < formula.length; i++) { + const char = formula[i]; + + if (char === '"') { + // Toggle string mode — handle escaped quotes ("") + if (inString && formula[i + 1] === '"') { + // Escaped quote inside string — keep both and skip + result += '""'; + i++; + continue; + } + inString = !inString; + result += char; + } else if (char === ';' && !inString) { + hasSemicolonSeparator = true; + result += ','; + } else { + result += char; + } + } + + const localeDetected = hasSemicolonSeparator ? 'semicolon' : 'comma'; + return { normalized: result, raw: formula, localeDetected }; +} + +/** + * Normalizes all formula strings found in a conditional format rule. + * Returns an augmented rule with normalized formulas and raw originals. + */ +export function normalizeConditionalFormatFormulas(rule: Record): Record { + const result = { ...rule }; + + // BooleanRule condition — may contain formula values + if (result.booleanRule?.condition?.values) { + const rawFormulas: string[] = []; + result.booleanRule = { + ...result.booleanRule, + condition: { + ...result.booleanRule.condition, + values: result.booleanRule.condition.values.map((v: any) => { + if (v.userEnteredValue?.startsWith?.('=')) { + const { normalized, raw } = normalizeFormulaLocale(v.userEnteredValue); + rawFormulas.push(raw); + return { ...v, userEnteredValue: normalized }; + } + return v; + }), + }, + }; + if (rawFormulas.length > 0) { + result._formulaLocaleRaw = rawFormulas; + } + } + + // GradientRule — thresholds may contain formulas + if (result.gradientRule) { + const rawFormulas: string[] = []; + const normalizeThreshold = (threshold: any) => { + if (!threshold) { + return threshold; + } + if (threshold.value?.startsWith?.('=')) { + const { normalized, raw } = normalizeFormulaLocale(threshold.value); + rawFormulas.push(raw); + return { ...threshold, value: normalized }; + } + return threshold; + }; + result.gradientRule = { + ...result.gradientRule, + minpoint: normalizeThreshold(result.gradientRule.minpoint), + midpoint: normalizeThreshold(result.gradientRule.midpoint), + maxpoint: normalizeThreshold(result.gradientRule.maxpoint), + }; + if (rawFormulas.length > 0) { + result._formulaLocaleRaw = rawFormulas; + } + } + + return result; +} diff --git a/src/utils/google-auth.ts b/src/utils/google-auth.ts index 8fca624..16245be 100644 --- a/src/utils/google-auth.ts +++ b/src/utils/google-auth.ts @@ -82,9 +82,11 @@ export function validateAuth(): void { // If using private key authentication, validate both fields are present if (!hasFileAuth && !hasJsonAuth && hasPrivateKeyAuth) { + /* v8 ignore next 3 */ if (!process.env.GOOGLE_PRIVATE_KEY) { throw new Error('GOOGLE_PRIVATE_KEY is required when using private key authentication'); } + /* v8 ignore next 3 */ if (!process.env.GOOGLE_CLIENT_EMAIL) { throw new Error('GOOGLE_CLIENT_EMAIL is required when using private key authentication'); } diff --git a/tests/unit/tools/append-values.test.ts b/tests/unit/tools/append-values.test.ts new file mode 100644 index 0000000..57f5429 --- /dev/null +++ b/tests/unit/tools/append-values.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleAppendValues } from '../../../src/tools/append-values.js'; +import * as googleAuth from '../../../src/utils/google-auth.js'; +import * as errorHandler from '../../../src/utils/error-handler.js'; + +vi.mock('../../../src/utils/google-auth.js'); +vi.mock('../../../src/utils/error-handler.js'); + +describe('handleAppendValues', () => { + const mockSheets = { + spreadsheets: { + values: { + append: vi.fn(), + }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(googleAuth.getAuthenticatedClient).mockResolvedValue(mockSheets as any); + vi.mocked(errorHandler.handleError).mockImplementation((err: any) => ({ + content: [{ type: 'text', text: `Error: ${err.message}` }], + })); + }); + + it('should successfully append values and return formatted response', async () => { + mockSheets.spreadsheets.values.append.mockResolvedValue({ + data: { + updates: { + updatedCells: 6, + updatedRange: 'Sheet1!A1:B3', + }, + }, + }); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + range: 'Sheet1!A1:B3', + values: [['a', 'b'], ['c', 'd'], ['e', 'f']], + }; + + const result = await handleAppendValues(input); + + expect(mockSheets.spreadsheets.values.append).toHaveBeenCalledWith({ + spreadsheetId: input.spreadsheetId, + range: input.range, + valueInputOption: 'USER_ENTERED', + insertDataOption: 'OVERWRITE', + requestBody: { values: input.values }, + }); + expect(result.content[0].text).toContain('6 cells'); + expect(result.content[0].text).toContain('Sheet1!A1:B3'); + }); + + it('should use || {} fallback when updates is undefined', async () => { + mockSheets.spreadsheets.values.append.mockResolvedValue({ + data: {}, + }); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + range: 'Sheet1!A:B', + values: [['x', 'y']], + }; + + // Should not throw - uses {} fallback + await handleAppendValues(input); + expect(mockSheets.spreadsheets.values.append).toHaveBeenCalled(); + }); + + it('should handle API errors by calling handleError', async () => { + const error = new Error('API error'); + mockSheets.spreadsheets.values.append.mockRejectedValue(error); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + range: 'Sheet1!A:B', + values: [['x']], + }; + + await handleAppendValues(input); + expect(errorHandler.handleError).toHaveBeenCalledWith(error); + }); + + it('should apply valueInputOption when provided', async () => { + mockSheets.spreadsheets.values.append.mockResolvedValue({ + data: { updates: { updatedCells: 1, updatedRange: 'Sheet1!A1' } }, + }); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + range: 'Sheet1!A:B', + values: [['x']], + valueInputOption: 'RAW', + insertDataOption: 'INSERT_ROWS', + }; + + await handleAppendValues(input); + + expect(mockSheets.spreadsheets.values.append).toHaveBeenCalledWith( + expect.objectContaining({ + valueInputOption: 'RAW', + insertDataOption: 'INSERT_ROWS', + }) + ); + }); +}); diff --git a/tests/unit/tools/batch-update-values.test.ts b/tests/unit/tools/batch-update-values.test.ts new file mode 100644 index 0000000..dd611fe --- /dev/null +++ b/tests/unit/tools/batch-update-values.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleBatchUpdateValues } from '../../../src/tools/batch-update-values.js'; +import * as googleAuth from '../../../src/utils/google-auth.js'; +import * as errorHandler from '../../../src/utils/error-handler.js'; + +vi.mock('../../../src/utils/google-auth.js'); +vi.mock('../../../src/utils/error-handler.js'); + +describe('handleBatchUpdateValues', () => { + const mockSheets = { + spreadsheets: { + values: { + batchUpdate: vi.fn(), + }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(googleAuth.getAuthenticatedClient).mockResolvedValue(mockSheets as any); + vi.mocked(errorHandler.handleError).mockImplementation((err: any) => ({ + content: [{ type: 'text', text: `Error: ${err.message}` }], + })); + }); + + it('should successfully batch update and sum updatedCells across responses', async () => { + mockSheets.spreadsheets.values.batchUpdate.mockResolvedValue({ + data: { + responses: [ + { updatedCells: 4 }, + { updatedCells: 6 }, + ], + }, + }); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + data: [ + { range: 'Sheet1!A1:B2', values: [['a', 'b'], ['c', 'd']] }, + { range: 'Sheet1!C1:D3', values: [['e', 'f'], ['g', 'h'], ['i', 'j']] }, + ], + }; + + const result = await handleBatchUpdateValues(input); + + expect(mockSheets.spreadsheets.values.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: input.spreadsheetId, + requestBody: { + valueInputOption: 'USER_ENTERED', + data: [ + { range: 'Sheet1!A1:B2', values: [['a', 'b'], ['c', 'd']] }, + { range: 'Sheet1!C1:D3', values: [['e', 'f'], ['g', 'h'], ['i', 'j']] }, + ], + }, + }); + expect(result.content[0].text).toContain('10 cells'); + }); + + it('should return 0 cells when responses is null/undefined', async () => { + mockSheets.spreadsheets.values.batchUpdate.mockResolvedValue({ + data: {}, + }); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + data: [{ range: 'Sheet1!A1', values: [['x']] }], + }; + + const result = await handleBatchUpdateValues(input); + expect(result.content[0].text).toContain('0 cells'); + }); + + it('should handle missing updatedCells in individual responses (uses || 0)', async () => { + mockSheets.spreadsheets.values.batchUpdate.mockResolvedValue({ + data: { + responses: [ + { updatedCells: 3 }, + {}, + { updatedCells: 2 }, + ], + }, + }); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + data: [ + { range: 'Sheet1!A1', values: [['a']] }, + { range: 'Sheet1!B1', values: [['b']] }, + { range: 'Sheet1!C1', values: [['c']] }, + ], + }; + + const result = await handleBatchUpdateValues(input); + expect(result.content[0].text).toContain('5 cells'); + }); + + it('should handle API errors by calling handleError', async () => { + const error = new Error('API failure'); + mockSheets.spreadsheets.values.batchUpdate.mockRejectedValue(error); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + data: [{ range: 'Sheet1!A1', values: [['x']] }], + }; + + await handleBatchUpdateValues(input); + expect(errorHandler.handleError).toHaveBeenCalledWith(error); + }); + + it('should use valueInputOption RAW when specified', async () => { + mockSheets.spreadsheets.values.batchUpdate.mockResolvedValue({ + data: { responses: [{ updatedCells: 1 }] }, + }); + + const input = { + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + data: [{ range: 'Sheet1!A1', values: [['x']] }], + valueInputOption: 'RAW', + }; + + await handleBatchUpdateValues(input); + + expect(mockSheets.spreadsheets.values.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ valueInputOption: 'RAW' }), + }) + ); + }); +}); diff --git a/tests/unit/tools/compare-ranges.test.ts b/tests/unit/tools/compare-ranges.test.ts new file mode 100644 index 0000000..3eae361 --- /dev/null +++ b/tests/unit/tools/compare-ranges.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleCompareRanges } from '../../../src/tools/compare-ranges.js'; +import * as googleAuth from '../../../src/utils/google-auth.js'; +import * as errorHandler from '../../../src/utils/error-handler.js'; + +vi.mock('../../../src/utils/google-auth.js'); +vi.mock('../../../src/utils/error-handler.js'); + +describe('handleCompareRanges', () => { + const mockSheets = { + spreadsheets: { + get: vi.fn(), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(googleAuth.getAuthenticatedClient).mockResolvedValue(mockSheets as any); + }); + + it('handles quoted sheet names and lowercase A1 ranges', async () => { + mockSheets.spreadsheets.get.mockResolvedValue({ + data: { + sheets: [ + { + properties: { title: 'My Sheet' }, + data: [ + { + startRow: 0, + startColumn: 0, + rowData: [ + { + values: [ + { userEnteredFormat: { textFormat: { bold: true } } }, + { userEnteredFormat: { backgroundColor: { red: 1 } } }, + ], + }, + ], + }, + { + startRow: 1, + startColumn: 0, + rowData: [ + { + values: [ + { userEnteredFormat: { textFormat: { bold: true } } }, + { userEnteredFormat: { backgroundColor: { red: 1 } } }, + ], + }, + ], + }, + ], + }, + ], + }, + }); + + const result = await handleCompareRanges({ + spreadsheetId: 'test-id', + rangeA: "'My Sheet'!a1:b1", + rangeB: "'My Sheet'!A2:B2", + }); + + const parsed = JSON.parse(result.content[0].text.split('\n\n')[1]); + expect(parsed.identical).toBe(true); + expect(parsed.diffCount).toBe(0); + expect(parsed.dimensions).toEqual({ rows: 1, cols: 2, totalCells: 2 }); + }); + + it('respects fields filter when comparing formatting', async () => { + mockSheets.spreadsheets.get.mockResolvedValue({ + data: { + sheets: [ + { + properties: { title: 'Sheet1' }, + data: [ + { + startRow: 0, + startColumn: 0, + rowData: [ + { + values: [ + { + userEnteredFormat: { + backgroundColor: { red: 1 }, + textFormat: { bold: true }, + }, + }, + ], + }, + ], + }, + { + startRow: 1, + startColumn: 0, + rowData: [ + { + values: [ + { + userEnteredFormat: { + backgroundColor: { red: 1 }, + textFormat: { bold: false }, + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }); + + const filteredResult = await handleCompareRanges({ + spreadsheetId: 'test-id', + rangeA: 'Sheet1!A1:A1', + rangeB: 'Sheet1!A2:A2', + fields: ['backgroundColor'], + }); + const filtered = JSON.parse(filteredResult.content[0].text.split('\n\n')[1]); + expect(filtered.identical).toBe(true); + expect(filtered.diffCount).toBe(0); + + const textOnlyResult = await handleCompareRanges({ + spreadsheetId: 'test-id', + rangeA: 'Sheet1!A1:A1', + rangeB: 'Sheet1!A2:A2', + fields: ['textFormat'], + }); + const textOnly = JSON.parse(textOnlyResult.content[0].text.split('\n\n')[1]); + expect(textOnly.identical).toBe(false); + expect(textOnly.diffCount).toBe(1); + expect(textOnly.diffs[0].diffs.textFormat).toBeDefined(); + }); + + it('returns an error when ranges have different dimensions', async () => { + vi.mocked(errorHandler.handleError).mockReturnValue({ + content: [{ type: 'text', text: 'Error: dimension mismatch' }], + } as any); + + const result = await handleCompareRanges({ + spreadsheetId: 'test-id', + rangeA: 'Sheet1!A1:A1', + rangeB: 'Sheet1!A1:B1', + }); + + expect(errorHandler.handleError).toHaveBeenCalled(); + expect(result.content[0].text).toContain('Error'); + }); +}); diff --git a/tests/unit/tools/create-chart.test.ts b/tests/unit/tools/create-chart.test.ts index 6b43f33..f38cfab 100644 --- a/tests/unit/tools/create-chart.test.ts +++ b/tests/unit/tools/create-chart.test.ts @@ -498,4 +498,307 @@ describe('handleCreateChart', () => { ); }); }); + + describe('JSON string inputs', () => { + it('should parse JSON string for position', async () => { + const positionObj = { + overlayPosition: { anchorCell: { sheetId: 123, rowIndex: 0, columnIndex: 5 } }, + }; + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: JSON.stringify(positionObj), + chartType: 'LINE', + series: [{ sourceRange: 'B1:B5' }], + }; + + mockValidateCreateChartInput.mockReturnValue({ ...input, position: positionObj }); + mockExtractSheetName.mockReturnValue({ range: 'B1:B5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 1 } } }] }, + }); + + const result = await handleCreateChart(input); + expect(result.content).toBeDefined(); + }); + + it('should parse JSON strings for backgroundColor, legend, domainAxis, leftAxis, rightAxis', async () => { + const baseInput = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'COLUMN', + series: [{ sourceRange: 'B1:B5' }], + backgroundColor: JSON.stringify({ red: 1, green: 1, blue: 1 }), + legend: JSON.stringify({ position: 'BOTTOM_LEGEND' }), + domainAxis: JSON.stringify({ title: 'X' }), + leftAxis: JSON.stringify({ title: 'Y' }), + rightAxis: JSON.stringify({ title: 'Y2' }), + }; + + mockValidateCreateChartInput.mockReturnValue({ + ...baseInput, + backgroundColor: { red: 1, green: 1, blue: 1 }, + legend: { position: 'BOTTOM_LEGEND' }, + domainAxis: { title: 'X' }, + leftAxis: { title: 'Y' }, + rightAxis: { title: 'Y2' }, + }); + mockExtractSheetName.mockReturnValue({ range: 'B1:B5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 2 } } }] }, + }); + + const result = await handleCreateChart(baseInput); + expect(result.content).toBeDefined(); + }); + }); + + describe('default chart type (COMBO/HISTOGRAM)', () => { + it('should use basicChart with chartType in default switch case', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'COMBO', + series: [{ sourceRange: 'B1:B5' }], + }; + + mockValidateCreateChartInput.mockReturnValue(input); + mockExtractSheetName.mockReturnValue({ range: 'B1:B5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 3 } } }] }, + }); + + const result = await handleCreateChart(input); + + expect(result.content[0].text).toContain('COMBO'); + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + requests: [ + expect.objectContaining({ + addChart: expect.objectContaining({ + chart: expect.objectContaining({ + spec: expect.objectContaining({ + basicChart: expect.objectContaining({ chartType: 'COMBO' }), + }), + }), + }), + }), + ], + }), + }) + ); + }); + }); + + describe('PIE chart where domain match is null', () => { + it('should skip pieChart.domain when range has no row bounds', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'PIE', + series: [{ sourceRange: 'Sheet1!B1:B' }], + }; + + mockValidateCreateChartInput.mockReturnValue(input); + // extractSheetName returns a range that doesn't match /[A-Z]+(\d+):[A-Z]+(\d+)/ + mockExtractSheetName.mockReturnValue({ sheetName: null, range: 'B:B' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 4 } } }] }, + }); + + const result = await handleCreateChart(input); + expect(result.content[0].text).toContain('PIE'); + }); + }); + + describe('NO_LEGEND position branch', () => { + it('should use NO_LEGEND directly when position is NO_LEGEND', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'LINE', + series: [{ sourceRange: 'B1:B5' }], + legend: { position: 'NO_LEGEND' }, + }; + + mockValidateCreateChartInput.mockReturnValue(input); + mockExtractSheetName.mockReturnValue({ range: 'B1:B5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 5 } } }] }, + }); + + const result = await handleCreateChart(input); + + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + requests: [ + expect.objectContaining({ + addChart: expect.objectContaining({ + chart: expect.objectContaining({ + spec: expect.objectContaining({ + basicChart: expect.objectContaining({ legendPosition: 'NO_LEGEND' }), + }), + }), + }), + }), + ], + }), + }) + ); + }); + + it('should use position directly when already has _LEGEND suffix', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'LINE', + series: [{ sourceRange: 'B1:B5' }], + legend: { position: 'LEFT_LEGEND' }, + }; + + mockValidateCreateChartInput.mockReturnValue(input); + mockExtractSheetName.mockReturnValue({ range: 'B1:B5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 6 } } }] }, + }); + + const result = await handleCreateChart(input); + + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + requests: [ + expect.objectContaining({ + addChart: expect.objectContaining({ + chart: expect.objectContaining({ + spec: expect.objectContaining({ + basicChart: expect.objectContaining({ legendPosition: 'LEFT_LEGEND' }), + }), + }), + }), + }), + ], + }), + }) + ); + }); + }); + + describe('basicChart domain auto-detect with no row bounds in range', () => { + it('should skip domain when range has no row numbers (match is null)', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'COLUMN', + series: [{ sourceRange: 'B:B' }], + }; + + mockValidateCreateChartInput.mockReturnValue(input); + // Return a range without row numbers → match will be null + mockExtractSheetName.mockReturnValue({ sheetName: null, range: 'B:B' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 8 } } }] }, + }); + + const result = await handleCreateChart(input); + expect(result.content[0].text).toContain('COLUMN'); + }); + }); + + describe('PIE chart with empty series (else-if false path)', () => { + it('should skip series setup when PIE chart has no series', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'PIE', + series: [], + }; + + mockValidateCreateChartInput.mockReturnValue(input); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 9 } } }] }, + }); + + const result = await handleCreateChart(input); + expect(result.content[0].text).toContain('PIE'); + }); + }); + + describe('domainRange without sheet prefix (uses actualSheetId)', () => { + it('should use actualSheetId when domainRange extractSheetName returns no sheetName', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'COLUMN', + series: [{ sourceRange: 'B1:B5' }], + domainRange: 'A1:A5', + }; + + mockValidateCreateChartInput.mockReturnValue(input); + // extractSheetName is called 3x: (1) init firstSeriesRange, (2) series loop, (3) domainRange + mockExtractSheetName + .mockReturnValueOnce({ sheetName: null, range: 'B1:B5' }) + .mockReturnValueOnce({ sheetName: null, range: 'B1:B5' }) + .mockReturnValueOnce({ sheetName: null, range: 'A1:A5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { spreadsheetId: 'test-spreadsheet-id', replies: [{ addChart: { chart: { chartId: 5 } } }] }, + }); + + const result = await handleCreateChart(input); + expect(result.content[0].text).toContain('COLUMN'); + expect(mockGetSheetId).not.toHaveBeenCalled(); + }); + }); + + describe('batchUpdate response with null replies', () => { + it('should return empty updatedReplies when response.data.replies is null', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'COLUMN', + series: [{ sourceRange: 'B1:B5' }], + }; + + mockValidateCreateChartInput.mockReturnValue(input); + mockExtractSheetName.mockReturnValue({ sheetName: null, range: 'B1:B5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { spreadsheetId: 'test-spreadsheet-id', replies: null }, + }); + + const result = await handleCreateChart(input); + expect(result.content[0].text).toContain('COLUMN'); + }); + }); + + describe('Axis configuration', () => { + it('should add domain, left, and right axes when all titles are provided', async () => { + const input = { + spreadsheetId: 'test-spreadsheet-id', + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 } } }, + chartType: 'LINE', + series: [{ sourceRange: 'B1:B5' }], + domainAxis: { title: 'Time' }, + leftAxis: { title: 'Value' }, + rightAxis: { title: 'Count' }, + altText: 'A line chart', + backgroundColor: { red: 1, green: 1, blue: 1 }, + }; + + mockValidateCreateChartInput.mockReturnValue(input); + mockExtractSheetName.mockReturnValue({ range: 'B1:B5' }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { replies: [{ addChart: { chart: { chartId: 7 } } }] }, + }); + + const result = await handleCreateChart(input); + + expect(result.content).toBeDefined(); + const batchUpdateCall = mockSheets.spreadsheets.batchUpdate.mock.calls[0][0]; + const chartSpec = batchUpdateCall.requestBody.requests[0].addChart.chart.spec; + expect(chartSpec.basicChart.axis).toHaveLength(3); + expect(chartSpec.altText).toBe('A line chart'); + expect(chartSpec.backgroundColor).toEqual({ red: 1, green: 1, blue: 1 }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/tools/get-border-map.test.ts b/tests/unit/tools/get-border-map.test.ts new file mode 100644 index 0000000..6460322 --- /dev/null +++ b/tests/unit/tools/get-border-map.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleGetBorderMap } from '../../../src/tools/get-border-map.js'; +import * as googleAuth from '../../../src/utils/google-auth.js'; +import * as errorHandler from '../../../src/utils/error-handler.js'; + +vi.mock('../../../src/utils/google-auth.js'); +vi.mock('../../../src/utils/error-handler.js'); + +describe('handleGetBorderMap', () => { + const mockSheets = { + spreadsheets: { + get: vi.fn(), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(googleAuth.getAuthenticatedClient).mockResolvedValue(mockSheets as any); + }); + + it('handles quoted sheet names and lowercase A1 ranges', async () => { + mockSheets.spreadsheets.get.mockResolvedValue({ + data: { + sheets: [ + { + properties: { title: 'My Sheet' }, + data: [ + { + rowData: [{ values: [{ userEnteredFormat: { borders: {} } }] }], + }, + ], + }, + ], + }, + }); + + const result = await handleGetBorderMap({ + spreadsheetId: 'test-id', + range: "'My Sheet'!a1:a1", + }); + + const parsed = JSON.parse(result.content[0].text.split('\n\n')[1]); + expect(parsed.sheetName).toBe('My Sheet'); + expect(parsed.dimensions).toEqual({ rows: 1, cols: 1 }); + }); + + it('returns an error for an invalid range format', async () => { + vi.mocked(errorHandler.handleError).mockReturnValue({ + content: [{ type: 'text', text: 'Error: invalid range' }], + } as any); + + const result = await handleGetBorderMap({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + }); + + expect(errorHandler.handleError).toHaveBeenCalled(); + expect(result.content[0].text).toContain('Error'); + }); + + it('resolves horizontal and vertical lines correctly for a minimal grid', async () => { + mockSheets.spreadsheets.get.mockResolvedValue({ + data: { + sheets: [ + { + properties: { title: 'Sheet1' }, + data: [ + { + rowData: [ + { + values: [ + { + userEnteredFormat: { + borders: { + bottom: { style: 'SOLID' }, + right: { style: 'DASHED' }, + }, + }, + }, + { + userEnteredFormat: { + borders: { + left: { style: 'DOTTED' }, + }, + }, + }, + ], + }, + { + values: [ + { + userEnteredFormat: { + borders: { + top: { style: 'DOUBLE' }, + right: { style: 'SOLID_MEDIUM' }, + }, + }, + }, + { + userEnteredFormat: { + borders: { + top: { style: 'SOLID' }, + }, + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }); + + const result = await handleGetBorderMap({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:B2', + }); + + const parsed = JSON.parse(result.content[0].text.split('\n\n')[1]); + expect(parsed.horizontalLines.data).toEqual([ + [null, null], + ['SOLID', 'SOLID'], + [null, null], + ]); + expect(parsed.verticalLines.data).toEqual([ + [null, 'DOTTED', null], + [null, 'SOLID_MEDIUM', null], + ]); + }); +}); diff --git a/tests/unit/tools/insert-rows.test.ts b/tests/unit/tools/insert-rows.test.ts index 64c474b..5e9e4ad 100644 --- a/tests/unit/tools/insert-rows.test.ts +++ b/tests/unit/tools/insert-rows.test.ts @@ -269,4 +269,87 @@ describe('handleInsertRows', () => { }); }); }); + + describe('edge cases', () => { + it('should default to row 0 when startRowIndex is undefined (??0 branch)', async () => { + vi.mocked(rangeHelpers.parseRange).mockReturnValue({ + sheetId: 0, + // startRowIndex and startColumnIndex intentionally omitted + } as any); + + const input = { + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + }; + + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ data: {} }); + + const result = await handleInsertRows(input); + + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + requests: [ + expect.objectContaining({ + insertDimension: expect.objectContaining({ + range: expect.objectContaining({ startIndex: 0, endIndex: 1 }), + }), + }), + ], + }), + }) + ); + expect(result).toMatchObject({ + content: [expect.objectContaining({ text: expect.stringContaining('row 1') })], + }); + }); + + it('should build range without sheet name when sheetName is null', async () => { + vi.mocked(rangeHelpers.extractSheetName).mockReturnValue({ + sheetName: null as any, + range: 'A5', + }); + + const input = { + spreadsheetId: 'test-id', + range: 'A5', + values: [['data']], + }; + + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ data: {} }); + mockSheets.spreadsheets.values.update.mockResolvedValue({ data: {} }); + + const result = await handleInsertRows(input); + + expect(mockSheets.spreadsheets.values.update).toHaveBeenCalledWith( + expect.objectContaining({ + range: expect.not.stringContaining("'"), + }) + ); + expect(result).toMatchObject({ + content: [expect.objectContaining({ text: expect.stringContaining('cells') })], + }); + }); + + it('should use "Sheet" fallback when sheetName is null and no values provided', async () => { + vi.mocked(rangeHelpers.extractSheetName).mockReturnValue({ + sheetName: null as any, + range: 'A5', + }); + + const input = { + spreadsheetId: 'test-id', + range: 'A5', + // no values — takes the no-values return path + }; + + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ data: {} }); + + const result = await handleInsertRows(input); + + expect(result).toMatchObject({ + content: [expect.objectContaining({ text: expect.stringContaining('"Sheet"') })], + }); + }); + }); }); \ No newline at end of file diff --git a/tests/unit/tools/update-values.test.ts b/tests/unit/tools/update-values.test.ts index 6ca1d3d..63a70a2 100644 --- a/tests/unit/tools/update-values.test.ts +++ b/tests/unit/tools/update-values.test.ts @@ -249,5 +249,30 @@ describe('handleUpdateValues', () => { isError: true, }); }); + + it('should use 0 fallback when updatedCells is undefined', async () => { + const input = { + spreadsheetId: 'test-id', + range: 'A1', + values: [['test']], + }; + + mockSheets.spreadsheets.values.update.mockResolvedValue({ + data: { + updatedRange: 'Sheet1!A1', + // updatedCells intentionally omitted + }, + }); + + const result = await handleUpdateValues(input); + + expect(result).toMatchObject({ + content: [ + expect.objectContaining({ + text: expect.stringContaining('0 cells'), + }), + ], + }); + }); }); }); \ No newline at end of file diff --git a/tests/unit/utils/error-messages.test.ts b/tests/unit/utils/error-messages.test.ts new file mode 100644 index 0000000..ec4ccef --- /dev/null +++ b/tests/unit/utils/error-messages.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { ERROR_MESSAGES } from '../../../src/utils/error-messages.js'; + +describe('ERROR_MESSAGES', () => { + describe('factory functions', () => { + it('REQUIRED_STRING should return correct message', () => { + expect(ERROR_MESSAGES.REQUIRED_STRING('name')).toBe( + 'name is required and must be a string' + ); + }); + + it('REQUIRED_NUMBER should return correct message', () => { + expect(ERROR_MESSAGES.REQUIRED_NUMBER('count')).toBe( + 'count is required and must be a number' + ); + }); + + it('REQUIRED_ARRAY should return correct message', () => { + expect(ERROR_MESSAGES.REQUIRED_ARRAY('items')).toBe( + 'items is required and must be a non-empty array' + ); + }); + + it('REQUIRED_OBJECT should return correct message', () => { + expect(ERROR_MESSAGES.REQUIRED_OBJECT('config')).toBe( + 'config is required and must be an object' + ); + }); + + it('REQUIRED_POSITIVE should return correct message', () => { + expect(ERROR_MESSAGES.REQUIRED_POSITIVE('rows')).toBe('rows must be a positive number'); + }); + + it('REQUIRED_NON_NEGATIVE should return correct message', () => { + expect(ERROR_MESSAGES.REQUIRED_NON_NEGATIVE('index')).toBe( + 'index must be a non-negative number' + ); + }); + + it('INVALID_COLOR_VALUE should return correct message', () => { + expect(ERROR_MESSAGES.INVALID_COLOR_VALUE('red')).toBe( + 'red color value must be between 0 and 1' + ); + }); + }); + + describe('string constants', () => { + it('INVALID_RANGE should be defined', () => { + expect(ERROR_MESSAGES.INVALID_RANGE).toBe( + 'Invalid range format. Use A1 notation (e.g., "Sheet1!A1:B10")' + ); + }); + + it('SPREADSHEET_ID_REQUIRED should be defined', () => { + expect(ERROR_MESSAGES.SPREADSHEET_ID_REQUIRED).toBe( + 'spreadsheetId is required and must be a string' + ); + }); + }); +}); diff --git a/tests/unit/utils/formatters.test.ts b/tests/unit/utils/formatters.test.ts index 8243f71..2f269a8 100644 --- a/tests/unit/utils/formatters.test.ts +++ b/tests/unit/utils/formatters.test.ts @@ -121,6 +121,16 @@ describe('formatValuesResponse', () => { const parsed = JSON.parse(result.content[0].text!); expect(parsed.columnCount).toBe(0); }); + + it('should handle null first row (columnCount = 0)', () => { + // values[0] is null/falsy — hits `: 0` branch in columnCount + const values = [null as any, ['A2', 'B2']]; + const result = formatValuesResponse(values); + + const parsed = JSON.parse(result.content[0].text!); + expect(parsed.columnCount).toBe(0); + expect(parsed.rowCount).toBe(2); + }); }); describe('formatBatchValuesResponse', () => { diff --git a/tests/unit/utils/formula-locale.test.ts b/tests/unit/utils/formula-locale.test.ts new file mode 100644 index 0000000..1c40bc3 --- /dev/null +++ b/tests/unit/utils/formula-locale.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeFormulaLocale, + normalizeConditionalFormatFormulas, +} from '../../../src/utils/formula-locale.js'; + +describe('normalizeFormulaLocale', () => { + it('replaces argument separators ; with , outside strings', () => { + const result = normalizeFormulaLocale('=IF(A1>0;"a;b";"no")'); + expect(result.normalized).toBe('=IF(A1>0,"a;b","no")'); + expect(result.localeDetected).toBe('semicolon'); + }); + + it('handles escaped quotes inside strings', () => { + const result = normalizeFormulaLocale('=IF(A1>0;"He said ""a;b""";"no")'); + expect(result.normalized).toBe('=IF(A1>0,"He said ""a;b""","no")'); + expect(result.localeDetected).toBe('semicolon'); + }); + + it('keeps formulas without separators unchanged and detects comma locale', () => { + const result = normalizeFormulaLocale('=NOW()'); + expect(result.normalized).toBe('=NOW()'); + expect(result.localeDetected).toBe('comma'); + }); + + it('returns unknown for non-formula input', () => { + const result = normalizeFormulaLocale('plain-text'); + expect(result).toEqual({ + normalized: 'plain-text', + raw: 'plain-text', + localeDetected: 'unknown', + }); + }); +}); + +describe('normalizeConditionalFormatFormulas', () => { + it('normalizes booleanRule formulas and stores raw formulas', () => { + const rule = { + booleanRule: { + condition: { + values: [{ userEnteredValue: '=IF(A1>0;"x";"y")' }, { userEnteredValue: 'TEXT' }], + }, + }, + }; + + const normalized = normalizeConditionalFormatFormulas(rule); + expect(normalized.booleanRule.condition.values[0].userEnteredValue).toBe('=IF(A1>0,"x","y")'); + expect(normalized._formulaLocaleRaw).toEqual(['=IF(A1>0;"x";"y")']); + }); + + it('normalizes gradientRule thresholds', () => { + const rule = { + gradientRule: { + minpoint: { value: '=A1;A2' }, + midpoint: { value: '50' }, + maxpoint: { value: '=B1;B2' }, + }, + }; + + const normalized = normalizeConditionalFormatFormulas(rule); + expect(normalized.gradientRule.minpoint.value).toBe('=A1,A2'); + expect(normalized.gradientRule.maxpoint.value).toBe('=B1,B2'); + expect(normalized._formulaLocaleRaw).toEqual(['=A1;A2', '=B1;B2']); + }); +}); diff --git a/tests/unit/utils/google-auth.test.ts b/tests/unit/utils/google-auth.test.ts new file mode 100644 index 0000000..b144379 --- /dev/null +++ b/tests/unit/utils/google-auth.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// google-auth uses module-level singletons, so we need to reset modules between tests +// that test different auth paths + +const VALID_SERVICE_ACCOUNT = JSON.stringify({ + type: 'service_account', + private_key: 'some-private-key', + client_email: 'test@project.iam.gserviceaccount.com', + project_id: 'my-project', +}); + +const VALID_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----\n'; +const VALID_CLIENT_EMAIL = 'test@project.iam.gserviceaccount.com'; + +describe('validateAuth', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + delete process.env.GOOGLE_SERVICE_ACCOUNT_KEY; + delete process.env.GOOGLE_PRIVATE_KEY; + delete process.env.GOOGLE_CLIENT_EMAIL; + delete process.env.GOOGLE_PROJECT_ID; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should throw when no auth method is provided', async () => { + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('No authentication method provided'); + }); + + it('should pass when GOOGLE_APPLICATION_CREDENTIALS is set', async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/key.json'; + process.env.GOOGLE_PROJECT_ID = 'my-project'; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).not.toThrow(); + }); + + it('should pass when valid GOOGLE_SERVICE_ACCOUNT_KEY is set', async () => { + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = VALID_SERVICE_ACCOUNT; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).not.toThrow(); + }); + + it('should throw when GOOGLE_SERVICE_ACCOUNT_KEY has invalid JSON', async () => { + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = 'not-valid-json'; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('GOOGLE_SERVICE_ACCOUNT_KEY contains invalid JSON'); + }); + + it('should throw when GOOGLE_SERVICE_ACCOUNT_KEY is missing type field', async () => { + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = JSON.stringify({ + private_key: 'key', + client_email: 'test@example.com', + }); + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('Invalid service account: type must be "service_account"'); + }); + + it('should throw when GOOGLE_SERVICE_ACCOUNT_KEY is missing private_key', async () => { + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = JSON.stringify({ + type: 'service_account', + client_email: 'test@example.com', + }); + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('Invalid service account: missing private_key'); + }); + + it('should throw when GOOGLE_SERVICE_ACCOUNT_KEY is missing client_email', async () => { + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = JSON.stringify({ + type: 'service_account', + private_key: 'key', + }); + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('Invalid service account: missing client_email'); + }); + + it('should pass when GOOGLE_PRIVATE_KEY and GOOGLE_CLIENT_EMAIL are valid', async () => { + process.env.GOOGLE_PRIVATE_KEY = VALID_PRIVATE_KEY; + process.env.GOOGLE_CLIENT_EMAIL = VALID_CLIENT_EMAIL; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).not.toThrow(); + }); + + it('should throw when private key format is invalid', async () => { + process.env.GOOGLE_PRIVATE_KEY = 'invalid-key-format'; + process.env.GOOGLE_CLIENT_EMAIL = VALID_CLIENT_EMAIL; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('GOOGLE_PRIVATE_KEY appears to be invalid'); + }); + + it('should throw when client email format is invalid', async () => { + process.env.GOOGLE_PRIVATE_KEY = VALID_PRIVATE_KEY; + process.env.GOOGLE_CLIENT_EMAIL = 'not-an-email'; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('GOOGLE_CLIENT_EMAIL appears to be invalid'); + }); + + it('should throw when GOOGLE_PROJECT_ID is missing for file auth', async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/key.json'; + // no GOOGLE_PROJECT_ID + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + expect(() => validateAuth()).toThrow('GOOGLE_PROJECT_ID environment variable is not set'); + }); + + it('should extract project_id from service account credentials', async () => { + delete process.env.GOOGLE_PROJECT_ID; + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = VALID_SERVICE_ACCOUNT; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + validateAuth(); + expect(process.env.GOOGLE_PROJECT_ID).toBe('my-project'); + }); + + it('should not override GOOGLE_PROJECT_ID when it is already set', async () => { + process.env.GOOGLE_PROJECT_ID = 'existing-project'; + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = VALID_SERVICE_ACCOUNT; + const { validateAuth } = await import('../../../src/utils/google-auth.js'); + validateAuth(); + expect(process.env.GOOGLE_PROJECT_ID).toBe('existing-project'); + }); +}); + +describe('getAuthClient', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + originalEnv = { ...process.env }; + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + delete process.env.GOOGLE_SERVICE_ACCOUNT_KEY; + delete process.env.GOOGLE_PRIVATE_KEY; + delete process.env.GOOGLE_CLIENT_EMAIL; + delete process.env.GOOGLE_PROJECT_ID; + vi.resetModules(); + // mock googleapis and google-auth-library + vi.doMock('googleapis', () => ({ + google: { + sheets: vi.fn().mockReturnValue({ spreadsheets: {} }), + }, + })); + vi.doMock('google-auth-library', () => ({ + GoogleAuth: vi.fn().mockImplementation(function(this: any, opts: any) { + this._opts = opts; + this.getClient = vi.fn().mockResolvedValue({}); + }), + })); + }); + + afterEach(() => { + process.env = originalEnv; + vi.resetModules(); + }); + + it('should create auth client with file credentials', async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/key.json'; + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + const client = await getAuthClient(); + expect(client).toBeDefined(); + expect((client as any)._opts.keyFilename).toBe('/path/to/key.json'); + }); + + it('should create auth client with JSON credentials', async () => { + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = VALID_SERVICE_ACCOUNT; + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + const client = await getAuthClient(); + expect(client).toBeDefined(); + expect((client as any)._opts.credentials).toBeDefined(); + }); + + it('should throw when GOOGLE_SERVICE_ACCOUNT_KEY JSON is invalid', async () => { + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = 'invalid json'; + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + await expect(getAuthClient()).rejects.toThrow('Failed to parse GOOGLE_SERVICE_ACCOUNT_KEY'); + }); + + it('should create auth client with private key credentials', async () => { + process.env.GOOGLE_PRIVATE_KEY = VALID_PRIVATE_KEY; + process.env.GOOGLE_CLIENT_EMAIL = VALID_CLIENT_EMAIL; + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + const client = await getAuthClient(); + expect(client).toBeDefined(); + expect((client as any)._opts.credentials).toBeDefined(); + expect((client as any)._opts.credentials.type).toBe('service_account'); + }); + + it('should create auth client with projectId when GOOGLE_PROJECT_ID is set', async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/key.json'; + process.env.GOOGLE_PROJECT_ID = 'my-test-project'; + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + const client = await getAuthClient(); + expect((client as any)._opts.projectId).toBe('my-test-project'); + }); + + it('should return cached auth client on second call', async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/key.json'; + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + const client1 = await getAuthClient(); + const client2 = await getAuthClient(); + expect(client1).toBe(client2); + }); + + it('should extract project_id from JSON credentials when not set', async () => { + delete process.env.GOOGLE_PROJECT_ID; + process.env.GOOGLE_SERVICE_ACCOUNT_KEY = VALID_SERVICE_ACCOUNT; + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + const client = await getAuthClient(); + expect(client).toBeDefined(); + expect((client as any)._opts.projectId).toBe('my-project'); + }); + + it('should create auth client using application default credentials when no auth env vars are set', async () => { + // No env vars set — all cleared in beforeEach; tests default/fallback auth path + const { getAuthClient } = await import('../../../src/utils/google-auth.js'); + const client = await getAuthClient(); + expect(client).toBeDefined(); + expect((client as any)._opts.keyFilename).toBeUndefined(); + expect((client as any)._opts.credentials).toBeUndefined(); + }); +}); + +describe('getAuthenticatedClient', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + originalEnv = { ...process.env }; + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/key.json'; + vi.resetModules(); + vi.doMock('googleapis', () => ({ + google: { + sheets: vi.fn().mockReturnValue({ spreadsheets: { values: {} } }), + }, + })); + vi.doMock('google-auth-library', () => ({ + GoogleAuth: vi.fn().mockImplementation(function(this: any) { + this.getClient = vi.fn().mockResolvedValue({}); + }), + })); + }); + + afterEach(() => { + process.env = originalEnv; + vi.resetModules(); + }); + + it('should return an authenticated sheets client', async () => { + const { getAuthenticatedClient } = await import('../../../src/utils/google-auth.js'); + const client = await getAuthenticatedClient(); + expect(client).toBeDefined(); + expect(client.spreadsheets).toBeDefined(); + }); + + it('should return cached sheets client on second call', async () => { + const { getAuthenticatedClient } = await import('../../../src/utils/google-auth.js'); + const client1 = await getAuthenticatedClient(); + const client2 = await getAuthenticatedClient(); + expect(client1).toBe(client2); + }); +}); diff --git a/tests/unit/utils/json-parser.test.ts b/tests/unit/utils/json-parser.test.ts new file mode 100644 index 0000000..b18ca29 --- /dev/null +++ b/tests/unit/utils/json-parser.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { parseJsonInput } from '../../../src/utils/json-parser.js'; + +describe('parseJsonInput', () => { + it('should return an object as-is when input is already an object', () => { + const obj = { key: 'value', num: 42 }; + const result = parseJsonInput(obj, 'test'); + expect(result).toBe(obj); + }); + + it('should parse a valid JSON string and return the object', () => { + const json = '{"key":"value","num":42}'; + const result = parseJsonInput(json, 'test'); + expect(result).toEqual({ key: 'value', num: 42 }); + }); + + it('should throw an error with the property name for invalid JSON string', () => { + expect(() => parseJsonInput('invalid json', 'myProp')).toThrow( + 'Invalid myProp: Expected object or valid JSON string' + ); + }); + + it('should throw an error for malformed JSON string', () => { + expect(() => parseJsonInput('{broken:', 'position')).toThrow( + 'Invalid position: Expected object or valid JSON string' + ); + }); + + it('should parse a nested JSON object string', () => { + const json = '{"outer":{"inner":"value"}}'; + const result = parseJsonInput(json, 'test'); + expect(result).toEqual({ outer: { inner: 'value' } }); + }); + + it('should return an array as-is when input is already an array', () => { + const arr = [1, 2, 3]; + const result = parseJsonInput(arr, 'test'); + expect(result).toBe(arr); + }); +}); diff --git a/tests/unit/utils/range-helpers.test.ts b/tests/unit/utils/range-helpers.test.ts index 199f740..36f21e2 100644 --- a/tests/unit/utils/range-helpers.test.ts +++ b/tests/unit/utils/range-helpers.test.ts @@ -4,6 +4,7 @@ import { parseRange, getSheetId, extractSheetName, + colIndexToLetter, findSheetOrThrow, gridRangeToA1, } from '../../../src/utils/range-helpers'; @@ -297,6 +298,18 @@ describe('getSheetId', () => { .rejects.toThrow('No sheets found in spreadsheet'); }); + it('should throw when first sheet has undefined sheetId', async () => { + // sheetsData.length > 0 but sheetId is undefined — falls through to throw + (mockSheets.spreadsheets.get as any).mockResolvedValue({ + data: { + sheets: [{ properties: { title: 'Sheet1' } }], + }, + }); + + await expect(getSheetId(mockSheets, 'spreadsheet-id')) + .rejects.toThrow('No sheets found in spreadsheet'); + }); + it('should handle case-sensitive sheet names', async () => { (mockSheets.spreadsheets.get as any).mockResolvedValue({ data: { @@ -352,6 +365,44 @@ describe('extractSheetName', () => { range: '', }); }); + + it('should return only range when sheetName part is empty string', () => { + // '!A1:B10' splits to ['', 'A1:B10'] — sheetName is '' (falsy), returns original range + const result = extractSheetName('!A1:B10'); + expect(result).toEqual({ + range: '!A1:B10', + }); + }); + + it('should strip double quotes from sheet name', () => { + const result = extractSheetName('"My Sheet"!A1:B10'); + expect(result).toEqual({ + sheetName: 'My Sheet', + range: 'A1:B10', + }); + }); +}); + +describe('colIndexToLetter', () => { + it('should convert 0 to A', () => { + expect(colIndexToLetter(0)).toBe('A'); + }); + + it('should convert 25 to Z', () => { + expect(colIndexToLetter(25)).toBe('Z'); + }); + + it('should convert 26 to AA', () => { + expect(colIndexToLetter(26)).toBe('AA'); + }); + + it('should convert 51 to AZ', () => { + expect(colIndexToLetter(51)).toBe('AZ'); + }); + + it('should convert 701 to ZZ', () => { + expect(colIndexToLetter(701)).toBe('ZZ'); + }); }); describe('findSheetOrThrow', () => { @@ -384,13 +435,9 @@ describe('findSheetOrThrow', () => { }); it('should be case-sensitive', () => { - const sheets = [ - { properties: { title: 'sheet1', sheetId: 0 } }, - ] as sheets_v4.Schema$Sheet[]; + const sheets = [{ properties: { title: 'sheet1', sheetId: 0 } }] as sheets_v4.Schema$Sheet[]; - expect(() => findSheetOrThrow(sheets, 'Sheet1')).toThrow( - 'Sheet "Sheet1" not found' - ); + expect(() => findSheetOrThrow(sheets, 'Sheet1')).toThrow('Sheet "Sheet1" not found'); }); }); diff --git a/tests/unit/utils/response-helpers.test.ts b/tests/unit/utils/response-helpers.test.ts new file mode 100644 index 0000000..ba80517 --- /dev/null +++ b/tests/unit/utils/response-helpers.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { + createTextResponse, + createJsonResponse, + createSuccessResponse, + createEmptyResponse, + createBatchResponse, + createOperationResponse, + formatRangeInfo, + formatSheetInfo, + createErrorResponse, +} from '../../../src/utils/response-helpers.js'; + +describe('createTextResponse', () => { + it('should return a text response with the given text', () => { + const result = createTextResponse('Hello, world!'); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Hello, world!' }], + }); + }); +}); + +describe('createJsonResponse', () => { + it('should return JSON response without message', () => { + const data = { key: 'value' }; + const result = createJsonResponse(data); + expect(result.content[0].text).toBe(JSON.stringify(data, null, 2)); + }); + + it('should return JSON response with message prefix', () => { + const data = { key: 'value' }; + const result = createJsonResponse(data, 'Success'); + expect(result.content[0].text).toBe(`Success\n\n${JSON.stringify(data, null, 2)}`); + }); +}); + +describe('createSuccessResponse', () => { + it('should return a text response with the success message', () => { + const result = createSuccessResponse('Operation completed'); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Operation completed' }], + }); + }); +}); + +describe('createEmptyResponse', () => { + it('should include context when context is provided', () => { + const result = createEmptyResponse('range: A1:B10'); + expect(result.content[0].text).toBe('No data found in range: A1:B10'); + }); + + it('should omit context when context is empty string', () => { + const result = createEmptyResponse(''); + expect(result.content[0].text).toBe('No data found'); + }); +}); + +describe('createBatchResponse', () => { + it('should return text summary without details', () => { + const result = createBatchResponse(5, 'rows'); + expect(result.content[0].text).toBe('Total rows: 5'); + }); + + it('should return JSON response when details are provided', () => { + const details = { items: [1, 2, 3] }; + const result = createBatchResponse(3, 'items', details); + expect(result.content[0].text).toContain('Total items: 3'); + expect(result.content[0].text).toContain(JSON.stringify(details, null, 2)); + }); +}); + +describe('createOperationResponse', () => { + it('should return message without details', () => { + const result = createOperationResponse('deleted', 3, 'sheets'); + expect(result.content[0].text).toBe('Successfully deleted 3 sheets'); + }); + + it('should return message with details appended', () => { + const result = createOperationResponse('updated', 5, 'cells', 'range A1:B5'); + expect(result.content[0].text).toBe('Successfully updated 5 cells: range A1:B5'); + }); +}); + +describe('formatRangeInfo', () => { + it('should include only range when no row/column counts provided', () => { + const result = formatRangeInfo('A1:B10'); + expect(result).toBe('range: A1:B10'); + }); + + it('should include row count when provided', () => { + const result = formatRangeInfo('A1:B10', 10); + expect(result).toBe('range: A1:B10, rows: 10'); + }); + + it('should include column count when provided', () => { + const result = formatRangeInfo('A1:B10', undefined, 2); + expect(result).toBe('range: A1:B10, columns: 2'); + }); + + it('should include both row and column counts when provided', () => { + const result = formatRangeInfo('A1:B10', 10, 2); + expect(result).toBe('range: A1:B10, rows: 10, columns: 2'); + }); +}); + +describe('formatSheetInfo', () => { + it('should return empty string for empty sheet object', () => { + const result = formatSheetInfo({}); + expect(result).toBe(''); + }); + + it('should include title when provided', () => { + const result = formatSheetInfo({ title: 'My Sheet' }); + expect(result).toBe('"My Sheet"'); + }); + + it('should include sheetId when provided', () => { + const result = formatSheetInfo({ sheetId: 123 }); + expect(result).toBe('ID: 123'); + }); + + it('should include index when provided', () => { + const result = formatSheetInfo({ index: 2 }); + expect(result).toBe('index: 2'); + }); + + it('should include all fields when all provided', () => { + const result = formatSheetInfo({ title: 'My Sheet', sheetId: 123, index: 2 }); + expect(result).toBe('"My Sheet" ID: 123 index: 2'); + }); +}); + +describe('createErrorResponse', () => { + it('should include the error message', () => { + const error = new Error('Something went wrong'); + const result = createErrorResponse(error); + expect(result.content[0].text).toBe('Error: Something went wrong'); + }); + + it('should use default message when error has no message', () => { + const result = createErrorResponse({}); + expect(result.content[0].text).toBe('Error: An unknown error occurred'); + }); +}); diff --git a/tests/unit/utils/validation-helpers.test.ts b/tests/unit/utils/validation-helpers.test.ts new file mode 100644 index 0000000..345f751 --- /dev/null +++ b/tests/unit/utils/validation-helpers.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Break the circular dependency between validation-helpers.ts and validators.ts +vi.mock('../../../src/utils/validators.js', () => ({ + validateSpreadsheetId: vi.fn((id: string) => !/[\s!]/.test(id)), + validateRange: vi.fn((range: string) => !/\s/.test(range) && !range.includes('!!')), +})); + +import { + withCommonValidation, + createRangeValidator, + createSheetValidator, + validateNumberInRange, + validateColor, + validateEnum, + COMMON_DEFAULTS, +} from '../../../src/utils/validation-helpers.js'; + +const VALID_SPREADSHEET_ID = '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms'; + +describe('withCommonValidation', () => { + describe('spreadsheetId field', () => { + const validator = withCommonValidation((input: any) => input, { + requiredFields: ['spreadsheetId'], + }); + + it('should pass for valid spreadsheet ID', () => { + expect(() => validator({ spreadsheetId: VALID_SPREADSHEET_ID })).not.toThrow(); + }); + + it('should throw for missing spreadsheet ID', () => { + expect(() => validator({})).toThrow('spreadsheetId is required'); + }); + + it('should throw for invalid spreadsheet ID format', () => { + expect(() => validator({ spreadsheetId: 'invalid id!' })).toThrow( + 'Invalid spreadsheet ID format' + ); + }); + }); + + describe('range field', () => { + const validator = withCommonValidation((input: any) => input, { + requiredFields: ['range'], + }); + + it('should pass for valid range', () => { + expect(() => validator({ range: 'Sheet1!A1:B10' })).not.toThrow(); + }); + + it('should throw for missing range', () => { + expect(() => validator({})).toThrow('range is required'); + }); + + it('should throw for invalid range format', () => { + expect(() => validator({ range: 'invalid range!!' })).toThrow(); + }); + }); + + describe('sheetId field', () => { + const validator = withCommonValidation((input: any) => input, { + requiredFields: ['sheetId'], + }); + + it('should pass for valid sheetId', () => { + expect(() => validator({ sheetId: 0 })).not.toThrow(); + }); + + it('should throw for missing sheetId', () => { + expect(() => validator({})).toThrow('sheetId is required'); + }); + + it('should throw for non-number sheetId', () => { + expect(() => validator({ sheetId: 'string' })).toThrow('sheetId is required'); + }); + }); + + describe('values field', () => { + const validator = withCommonValidation((input: any) => input, { + requiredFields: ['values'], + }); + + it('should pass for valid values array', () => { + expect(() => validator({ values: [['a', 'b']] })).not.toThrow(); + }); + + it('should throw for missing values', () => { + expect(() => validator({})).toThrow('values is required'); + }); + }); + + describe('title field', () => { + const validator = withCommonValidation((input: any) => input, { + requiredFields: ['title'], + }); + + it('should pass for valid title', () => { + expect(() => validator({ title: 'My Title' })).not.toThrow(); + }); + + it('should throw for missing title', () => { + expect(() => validator({})).toThrow('title is required'); + }); + }); + + describe('ranges field', () => { + const validator = withCommonValidation((input: any) => input, { + requiredFields: ['ranges'], + }); + + it('should pass for valid ranges array', () => { + expect(() => validator({ ranges: ['Sheet1!A1:B10', 'Sheet1!C1:D10'] })).not.toThrow(); + }); + + it('should throw for missing ranges', () => { + expect(() => validator({})).toThrow('ranges is required'); + }); + + it('should throw for empty ranges array', () => { + expect(() => validator({ ranges: [] })).toThrow('ranges is required'); + }); + + it('should throw for invalid range in ranges', () => { + expect(() => validator({ ranges: ['invalid range!!'] })).toThrow('Invalid range format'); + }); + }); + + describe('data field', () => { + const validator = withCommonValidation((input: any) => input, { + requiredFields: ['data'], + }); + + it('should pass for valid data array', () => { + expect(() => + validator({ data: [{ range: 'Sheet1!A1:B10', values: [['a']] }] }) + ).not.toThrow(); + }); + + it('should throw for missing data', () => { + expect(() => validator({})).toThrow('data is required'); + }); + + it('should throw for empty data array', () => { + expect(() => validator({ data: [] })).toThrow('data is required'); + }); + + it('should throw for data item without range', () => { + expect(() => validator({ data: [{ values: [['a']] }] })).toThrow( + 'Each data item must have range and values properties' + ); + }); + + it('should throw for data item with invalid range', () => { + expect(() => validator({ data: [{ range: 'invalid!!', values: [['a']] }] })).toThrow( + 'Invalid range format' + ); + }); + }); + + describe('defaults', () => { + it('should apply defaults to undefined fields', () => { + const validator = withCommonValidation((input: any) => input, { + defaults: { foo: 'bar', count: 1 }, + }); + const result = validator({ existing: 'value' }); + expect(result).toEqual({ existing: 'value', foo: 'bar', count: 1 }); + }); + + it('should not override existing field values with defaults', () => { + const validator = withCommonValidation((input: any) => input, { + defaults: { foo: 'default' }, + }); + const result = validator({ foo: 'custom' }); + expect(result.foo).toBe('custom'); + }); + }); +}); + +describe('createRangeValidator', () => { + it('should create a validator for spreadsheetId and range', () => { + const validator = createRangeValidator(); + expect(() => + validator({ spreadsheetId: VALID_SPREADSHEET_ID, range: 'Sheet1!A1:B10' }) + ).not.toThrow(); + }); + + it('should run additional validation when provided', () => { + const validator = createRangeValidator((input: any) => { + if (!input.extraField) throw new Error('extraField is required'); + }); + expect(() => + validator({ spreadsheetId: VALID_SPREADSHEET_ID, range: 'A1:B10', extraField: 'value' }) + ).not.toThrow(); + expect(() => + validator({ spreadsheetId: VALID_SPREADSHEET_ID, range: 'A1:B10' }) + ).toThrow('extraField is required'); + }); + + it('should apply defaults when provided', () => { + const validator = createRangeValidator(undefined, { valueInputOption: 'USER_ENTERED' }); + const result = validator({ spreadsheetId: VALID_SPREADSHEET_ID, range: 'A1:B10' }); + expect((result as any).valueInputOption).toBe('USER_ENTERED'); + }); +}); + +describe('createSheetValidator', () => { + it('should create a validator for spreadsheetId and sheetId', () => { + const validator = createSheetValidator(); + expect(() => + validator({ spreadsheetId: VALID_SPREADSHEET_ID, sheetId: 0 }) + ).not.toThrow(); + }); + + it('should run additional validation when provided', () => { + const validator = createSheetValidator((input: any) => { + if (!input.extra) throw new Error('extra is required'); + }); + expect(() => + validator({ spreadsheetId: VALID_SPREADSHEET_ID, sheetId: 0, extra: 'value' }) + ).not.toThrow(); + expect(() => + validator({ spreadsheetId: VALID_SPREADSHEET_ID, sheetId: 0 }) + ).toThrow('extra is required'); + }); + + it('should apply defaults when provided', () => { + const validator = createSheetValidator(undefined, { title: 'Default' }); + const result = validator({ spreadsheetId: VALID_SPREADSHEET_ID, sheetId: 0 }); + expect((result as any).title).toBe('Default'); + }); +}); + +describe('validateNumberInRange', () => { + it('should not throw when value is within range', () => { + expect(() => validateNumberInRange(0.5, 'alpha', 0, 1)).not.toThrow(); + }); + + it('should not throw when value is undefined', () => { + expect(() => validateNumberInRange(undefined, 'alpha', 0, 1)).not.toThrow(); + }); + + it('should throw when value is below minimum', () => { + expect(() => validateNumberInRange(-0.1, 'alpha', 0, 1)).toThrow( + 'alpha must be between 0 and 1' + ); + }); + + it('should throw when value is above maximum', () => { + expect(() => validateNumberInRange(1.1, 'alpha', 0, 1)).toThrow( + 'alpha must be between 0 and 1' + ); + }); + + it('should throw when value is not a number', () => { + expect(() => validateNumberInRange('string' as any, 'alpha', 0, 1)).toThrow( + 'alpha must be between 0 and 1' + ); + }); + + it('should accept boundary values', () => { + expect(() => validateNumberInRange(0, 'alpha', 0, 1)).not.toThrow(); + expect(() => validateNumberInRange(1, 'alpha', 0, 1)).not.toThrow(); + }); +}); + +describe('validateColor', () => { + it('should not throw for undefined color', () => { + expect(() => validateColor(undefined, 'backgroundColor')).not.toThrow(); + }); + + it('should not throw for valid color object', () => { + expect(() => + validateColor({ red: 0.5, green: 0.3, blue: 1, alpha: 0.8 }, 'backgroundColor') + ).not.toThrow(); + }); + + it('should throw when color is not an object', () => { + expect(() => validateColor('red', 'backgroundColor')).toThrow( + 'backgroundColor must be an object' + ); + }); + + it('should throw when a color component is out of range', () => { + expect(() => + validateColor({ red: 1.5 }, 'backgroundColor') + ).toThrow('backgroundColor.red must be between 0 and 1'); + }); + + it('should ignore undefined color components', () => { + expect(() => validateColor({ red: 0.5 }, 'color')).not.toThrow(); + }); +}); + +describe('validateEnum', () => { + const validValues = ['ROWS', 'COLUMNS'] as const; + + it('should not throw when value is undefined', () => { + expect(() => validateEnum(undefined, 'dimension', validValues, 'Invalid dimension')).not.toThrow(); + }); + + it('should not throw for valid enum value', () => { + expect(() => validateEnum('ROWS', 'dimension', validValues, 'Invalid dimension')).not.toThrow(); + }); + + it('should throw for invalid enum value', () => { + expect(() => + validateEnum('DIAGONAL', 'dimension', validValues, 'Invalid dimension') + ).toThrow('Invalid dimension'); + }); +}); + +describe('COMMON_DEFAULTS', () => { + it('should export correct default values', () => { + expect(COMMON_DEFAULTS.majorDimension).toBe('ROWS'); + expect(COMMON_DEFAULTS.valueInputOption).toBe('USER_ENTERED'); + expect(COMMON_DEFAULTS.insertDataOption).toBe('OVERWRITE'); + }); +}); diff --git a/tests/unit/utils/validators.test.ts b/tests/unit/utils/validators.test.ts index 10b03bf..fb7f68d 100644 --- a/tests/unit/utils/validators.test.ts +++ b/tests/unit/utils/validators.test.ts @@ -20,6 +20,11 @@ import { validateUnmergeCellsInput, validateAddConditionalFormattingInput, validateInsertRowsInput, + validateBatchDeleteSheetsInput, + validateBatchFormatCellsInput, + validateCreateChartInput, + validateUpdateChartInput, + validateDeleteChartInput, } from '../../../src/utils/validators'; import { testSpreadsheetIds, testRanges, testValues, testInputs, testErrors } from '../../fixtures/test-data'; @@ -102,6 +107,16 @@ describe('validateRange', () => { expect(validateRange('Sheet1!123')).toBe(false); // no column letter expect(validateRange('Sheet1!1A')).toBe(false); // wrong order }); + + it('should reject range with sheet name but empty cell range', () => { + // parts = ['Sheet1', ''] — cellRange is '' (falsy) → false + expect(validateRange('Sheet1!')).toBe(false); + }); + + it('should reject empty string range', () => { + // parts = [''] — else block, cellRange is '' (falsy) → false + expect(validateRange('')).toBe(false); + }); }); describe('case sensitivity', () => { @@ -325,6 +340,13 @@ describe('validateBatchUpdateValuesInput', () => { data: [{ values: [['test']] }], // missing range })).toThrow('Each data item must have range and values properties'); }); + + it('should throw error for invalid range format in data item', () => { + expect(() => validateBatchUpdateValuesInput({ + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + data: [{ range: 'invalid!!range', values: [[]] }], + })).toThrow('Invalid range format'); + }); }); describe('validateCreateSpreadsheetInput', () => { @@ -454,6 +476,12 @@ describe('validateUpdateSheetPropertiesInput', () => { const result = validateUpdateSheetPropertiesInput(input); expect(result).toEqual(input); }); + + it('should throw for missing sheetId', () => { + expect(() => validateUpdateSheetPropertiesInput({ + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + })).toThrow('sheetId is required'); + }); }); describe('validateCopyToInput', () => { @@ -581,6 +609,13 @@ describe('validateMergeCellsInput', () => { range: 'A1:B2', })).toThrow('mergeType is required'); }); + + it('should throw error for missing range', () => { + expect(() => validateMergeCellsInput({ + spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', + mergeType: 'MERGE_ALL', + })).toThrow('range is required'); + }); }); describe('validateUnmergeCellsInput', () => { @@ -799,4 +834,360 @@ describe('validateInsertRowsInput', () => { expect(result.inheritFromBefore).toBe(false); expect(result.valueInputOption).toBe('USER_ENTERED'); }); +}); + +const VALID_ID = '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms'; + +describe('validateBatchDeleteSheetsInput', () => { + it('should accept valid input', () => { + const result = validateBatchDeleteSheetsInput({ + spreadsheetId: VALID_ID, + sheetIds: [0, 1, 2], + }); + expect(result.sheetIds).toEqual([0, 1, 2]); + }); + + it('should throw when sheetIds is missing', () => { + expect(() => validateBatchDeleteSheetsInput({ spreadsheetId: VALID_ID })).toThrow(); + }); + + it('should throw when sheetIds is empty array', () => { + expect(() => validateBatchDeleteSheetsInput({ spreadsheetId: VALID_ID, sheetIds: [] })).toThrow(); + }); + + it('should throw when sheetIds contains non-number', () => { + expect(() => + validateBatchDeleteSheetsInput({ spreadsheetId: VALID_ID, sheetIds: ['a'] }) + ).toThrow('Each sheetId must be a number'); + }); +}); + +describe('validateBatchFormatCellsInput', () => { + it('should accept valid input', () => { + const result = validateBatchFormatCellsInput({ + spreadsheetId: VALID_ID, + formatRequests: [{ range: 'Sheet1!A1', format: { bold: true } }], + }); + expect(result.formatRequests).toHaveLength(1); + }); + + it('should throw when formatRequests is missing', () => { + expect(() => validateBatchFormatCellsInput({ spreadsheetId: VALID_ID })).toThrow(); + }); + + it('should throw when formatRequests is empty', () => { + expect(() => validateBatchFormatCellsInput({ spreadsheetId: VALID_ID, formatRequests: [] })).toThrow(); + }); + + it('should throw when a request is missing range', () => { + expect(() => + validateBatchFormatCellsInput({ + spreadsheetId: VALID_ID, + formatRequests: [{ format: {} }], + }) + ).toThrow('Each format request must have a range property'); + }); + + it('should throw when a request range is invalid', () => { + expect(() => + validateBatchFormatCellsInput({ + spreadsheetId: VALID_ID, + formatRequests: [{ range: 'invalid range', format: {} }], + }) + ).toThrow('Invalid range format'); + }); + + it('should throw when a request is missing format', () => { + expect(() => + validateBatchFormatCellsInput({ + spreadsheetId: VALID_ID, + formatRequests: [{ range: 'Sheet1!A1' }], + }) + ).toThrow('Each format request must have a format property'); + }); +}); + +const VALID_POSITION = { + overlayPosition: { + anchorCell: { sheetId: 0, rowIndex: 0, columnIndex: 0 }, + }, +}; + +const VALID_SERIES = [{ sourceRange: 'Sheet1!A1:A10' }]; + +describe('validateCreateChartInput', () => { + it('should accept a valid minimal input', () => { + const result = validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: VALID_SERIES, + }); + expect(result.chartType).toBe('LINE'); + }); + + it('should throw when position is missing', () => { + expect(() => + validateCreateChartInput({ spreadsheetId: VALID_ID, chartType: 'LINE', series: VALID_SERIES }) + ).toThrow(); + }); + + it('should throw when chartType is missing', () => { + expect(() => + validateCreateChartInput({ spreadsheetId: VALID_ID, position: VALID_POSITION, series: VALID_SERIES }) + ).toThrow(); + }); + + it('should throw when chartType is invalid', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'UNKNOWN', + series: VALID_SERIES, + }) + ).toThrow(); + }); + + it('should throw when series is missing', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + }) + ).toThrow(); + }); + + it('should throw when series is empty', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: [], + }) + ).toThrow(); + }); + + it('should throw when overlayPosition is missing', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: {}, + chartType: 'LINE', + series: VALID_SERIES, + }) + ).toThrow('position.overlayPosition is required'); + }); + + it('should throw when anchorCell is missing', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: { overlayPosition: {} }, + chartType: 'LINE', + series: VALID_SERIES, + }) + ).toThrow('position.overlayPosition.anchorCell is required'); + }); + + it('should throw when anchorCell.sheetId is missing', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: { overlayPosition: { anchorCell: { rowIndex: 0, columnIndex: 0 } } }, + chartType: 'LINE', + series: VALID_SERIES, + }) + ).toThrow('position.overlayPosition.anchorCell.sheetId'); + }); + + it('should throw when anchorCell.rowIndex is missing', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: { overlayPosition: { anchorCell: { sheetId: 0, columnIndex: 0 } } }, + chartType: 'LINE', + series: VALID_SERIES, + }) + ).toThrow('position.overlayPosition.anchorCell.rowIndex'); + }); + + it('should throw when anchorCell.columnIndex is missing', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: { overlayPosition: { anchorCell: { sheetId: 0, rowIndex: 0 } } }, + chartType: 'LINE', + series: VALID_SERIES, + }) + ).toThrow('position.overlayPosition.anchorCell.columnIndex'); + }); + + it('should throw when series item is missing sourceRange', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: [{}], + }) + ).toThrow('Each series must have a sourceRange property'); + }); + + it('should throw when series sourceRange is invalid', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: [{ sourceRange: 'invalid range' }], + }) + ).toThrow('Invalid series range format'); + }); + + it('should throw when series targetAxis is invalid', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: [{ sourceRange: 'Sheet1!A1:A10', targetAxis: 'INVALID' }], + }) + ).toThrow(); + }); + + it('should accept valid targetAxis LEFT_AXIS', () => { + const result = validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: [{ sourceRange: 'Sheet1!A1:A10', targetAxis: 'LEFT_AXIS' }], + }); + expect(result.series[0].targetAxis).toBe('LEFT_AXIS'); + }); + + it('should accept valid targetAxis RIGHT_AXIS', () => { + const result = validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: [{ sourceRange: 'Sheet1!A1:A10', targetAxis: 'RIGHT_AXIS' }], + }); + expect(result.series[0].targetAxis).toBe('RIGHT_AXIS'); + }); + + it('should throw when domainRange is invalid', () => { + expect(() => + validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: VALID_SERIES, + domainRange: 'invalid', + }) + ).toThrow('Invalid domain range format'); + }); + + it('should accept valid domainRange', () => { + const result = validateCreateChartInput({ + spreadsheetId: VALID_ID, + position: VALID_POSITION, + chartType: 'LINE', + series: VALID_SERIES, + domainRange: 'Sheet1!A1:A10', + }); + expect(result.domainRange).toBe('Sheet1!A1:A10'); + }); +}); + +describe('validateUpdateChartInput', () => { + it('should accept valid minimal input', () => { + const result = validateUpdateChartInput({ + spreadsheetId: VALID_ID, + chartId: 123, + }); + expect(result.chartId).toBe(123); + }); + + it('should throw when chartId is missing', () => { + expect(() => validateUpdateChartInput({ spreadsheetId: VALID_ID })).toThrow(); + }); + + it('should throw when chartId is not a number', () => { + expect(() => validateUpdateChartInput({ spreadsheetId: VALID_ID, chartId: 'abc' })).toThrow(); + }); + + it('should throw when chartType is invalid', () => { + expect(() => + validateUpdateChartInput({ spreadsheetId: VALID_ID, chartId: 1, chartType: 'UNKNOWN' }) + ).toThrow(); + }); + + it('should accept valid optional chartType', () => { + const result = validateUpdateChartInput({ + spreadsheetId: VALID_ID, + chartId: 1, + chartType: 'BAR', + }); + expect(result.chartType).toBe('BAR'); + }); + + it('should throw when series is empty array', () => { + expect(() => + validateUpdateChartInput({ spreadsheetId: VALID_ID, chartId: 1, series: [] }) + ).toThrow(); + }); + + it('should throw when series item is missing sourceRange', () => { + expect(() => + validateUpdateChartInput({ spreadsheetId: VALID_ID, chartId: 1, series: [{}] }) + ).toThrow('Each series must have a sourceRange property'); + }); + + it('should throw when series sourceRange is invalid', () => { + expect(() => + validateUpdateChartInput({ + spreadsheetId: VALID_ID, + chartId: 1, + series: [{ sourceRange: 'bad range' }], + }) + ).toThrow('Invalid series range format'); + }); + + it('should throw when series targetAxis is invalid', () => { + expect(() => + validateUpdateChartInput({ + spreadsheetId: VALID_ID, + chartId: 1, + series: [{ sourceRange: 'Sheet1!A1:A10', targetAxis: 'INVALID' }], + }) + ).toThrow(); + }); + + it('should accept series with valid targetAxis', () => { + // Covers the branch where targetAxis is truthy but IS in valid list → no throw + const result = validateUpdateChartInput({ + spreadsheetId: VALID_ID, + chartId: 1, + series: [{ sourceRange: 'Sheet1!A1:A10', targetAxis: 'LEFT_AXIS' }], + }); + expect(result.series).toBeDefined(); + }); +}); + +describe('validateDeleteChartInput', () => { + it('should accept valid input', () => { + const result = validateDeleteChartInput({ spreadsheetId: VALID_ID, chartId: 42 }); + expect(result.chartId).toBe(42); + }); + + it('should throw when chartId is missing', () => { + expect(() => validateDeleteChartInput({ spreadsheetId: VALID_ID })).toThrow(); + }); + + it('should throw when chartId is not a number', () => { + expect(() => validateDeleteChartInput({ spreadsheetId: VALID_ID, chartId: 'abc' })).toThrow(); + }); }); \ No newline at end of file