feat: Start refactoring code base to be testable

Implement a spreadsheet service abstraction, GAS integration, and Jest testing setup.
This commit is contained in:
Ben Miller
2025-12-25 03:52:16 -07:00
parent 85cdfe1443
commit 3c6130778e
10 changed files with 5060 additions and 57 deletions

35
src/Product.test.ts Normal file
View File

@ -0,0 +1,35 @@
import { Product } from "./Product";
import { MockSpreadsheetService } from "./services/MockSpreadsheetService";
describe("Product", () => {
let mockService: MockSpreadsheetService;
beforeEach(() => {
// Setup mock data
const headers = ["sku", "title", "price", "weight_grams"];
const productData = ["TEST-SKU-1", "Test Product", 10.99, 500];
// product_inventory sheet: Row 1 = headers, Row 2 = data
mockService = new MockSpreadsheetService({
product_inventory: [
headers,
productData
]
});
});
it("should load data from inventory sheet using service", () => {
const product = new Product("TEST-SKU-1", mockService);
expect(product.sku).toBe("TEST-SKU-1");
expect(product.title).toBe("Test Product");
expect(product.price).toBe(10.99);
expect(product.weight_grams).toBe(500);
});
it("should throw error if SKU not found", () => {
expect(() => {
new Product("NON-EXISTENT-SKU", mockService);
}).toThrow("product sku 'NON-EXISTENT-SKU' not found in product_inventory");
});
});

View File

@ -8,16 +8,12 @@ import {
ShopifyProductVariant,
ShopifyProductSetQuery,
ShopifyVariant,
VariantOptionValueInput,
formatGqlForJSON,
} from "./shopifyApi"
import * as shopify from "shopify-admin-api-typings"
import {
getCellRangeByColumnName,
getRowByColumnValue,
vlookupByColumns,
} from "./sheetUtils"
import { Config } from "./config"
import { ISpreadsheetService } from "./interfaces/ISpreadsheetService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
export class Product {
shopify_id: string = ""
@ -46,12 +42,15 @@ export class Product {
shopify_default_option_value_id: string = ""
shopify_status: string = ""
constructor(sku: string = "") {
private sheetService: ISpreadsheetService
constructor(sku: string = "", sheetService: ISpreadsheetService = new GASSpreadsheetService()) {
this.sheetService = sheetService;
if (sku == "") {
return
}
this.sku = sku
let productRow = getRowByColumnValue("product_inventory", "sku", sku)
let productRow = this.sheetService.getRowNumberByColumnValue("product_inventory", "sku", sku)
if (productRow == undefined) {
throw new Error(
"product sku '" + sku + "' not found in product_inventory"
@ -61,15 +60,10 @@ export class Product {
}
ImportFromInventory(row: number) {
let productInventorySheet =
SpreadsheetApp.getActiveSpreadsheet().getSheetByName("product_inventory")
let headers = productInventorySheet
.getRange(1, 1, 1, productInventorySheet.getLastColumn())
.getValues()[0]
let headers = this.sheetService.getHeaders("product_inventory")
console.log("headers" + headers)
let productValues = productInventorySheet
.getRange(row, 1, 1, headers.length)
.getValues()[0]
let productValues = this.sheetService.getRowData("product_inventory", row)
console.log("productValues:" + productValues)
console.log("productValues:" + productValues)
for (let i = 0; i < headers.length; i++) {
if (this.hasOwnProperty(headers[i])) {
@ -110,31 +104,26 @@ export class Product {
this.shopify_id = this.shopify_product.id.toString()
this.shopify_default_variant_id = product.variants.nodes[0].id
this.shopify_default_option_id = product.options[0].id
this.shopify_default_option_id = product.options[0].id
this.shopify_default_option_value_id = product.options[0].optionValues[0].id
let productInventorySheet =
SpreadsheetApp.getActiveSpreadsheet().getSheetByName("product_inventory")
let row = getRowByColumnValue("product_inventory", "sku", this.sku)
getCellRangeByColumnName(productInventorySheet, "shopify_id", row).setValue(
this.shopify_id
)
let row = this.sheetService.getRowNumberByColumnValue("product_inventory", "sku", this.sku)
if (row) {
this.sheetService.setCellValueByColumnName("product_inventory", row, "shopify_id", this.shopify_id)
}
}
ShopifyCategory(): string {
return vlookupByColumns(
"values",
"product_type",
this.product_type,
"shopify_category"
)
return this.sheetService.getCellValueByColumnName("values",
this.sheetService.getRowNumberByColumnValue("values", "product_type", this.product_type) || 0,
"shopify_category"
) || ""
}
EbayCategory(): string {
return vlookupByColumns(
"values",
"product_type",
this.product_type,
"ebay_category_id"
)
return this.sheetService.getCellValueByColumnName("values",
this.sheetService.getRowNumberByColumnValue("values", "product_type", this.product_type) || 0,
"ebay_category_id"
) || ""
}
ToShopifyProductSet() {
@ -188,12 +177,11 @@ export class Product {
let response = shop.shopifyGraphQLAPI(query.JSON)
let product = response.content.data.productSet.product
this.shopify_id = product.id
let productInventorySheet =
SpreadsheetApp.getActiveSpreadsheet().getSheetByName("product_inventory")
let row = getRowByColumnValue("product_inventory", "sku", this.sku)
getCellRangeByColumnName(productInventorySheet, "shopify_id", row).setValue(
this.shopify_id
)
let row = this.sheetService.getRowNumberByColumnValue("product_inventory", "sku", this.sku)
if (row) {
this.sheetService.setCellValueByColumnName("product_inventory", row, "shopify_id", this.shopify_id)
}
let item: shopify.InventoryItem
do {
console.log("UpdateShopifyProduct: attempting to get inventory item")
@ -335,7 +323,7 @@ export class Product {
CreatePhotoFolder() {
console.log("Product.CreatePhotoFolder()");
createPhotoFolderForSku(new(Config), this.sku);
createPhotoFolderForSku(new(Config), this.sku, this.sheetService);
}
PublishToShopifyOnlineStore(shop: Shop) {
@ -383,7 +371,7 @@ export class Product {
}
}
export function createPhotoFolderForSku(config: Config, sku: string) {
export function createPhotoFolderForSku(config: Config, sku: string, sheetService: ISpreadsheetService = new GASSpreadsheetService()) {
console.log(`createPhotoFolderForSku('${sku}')`)
if (!config.productPhotosFolderId) {
console.log(
@ -392,19 +380,13 @@ export function createPhotoFolderForSku(config: Config, sku: string) {
return
}
const productInventorySheet =
SpreadsheetApp.getActiveSpreadsheet().getSheetByName("product_inventory")
const row = getRowByColumnValue("product_inventory", "sku", sku)
const row = sheetService.getRowNumberByColumnValue("product_inventory", "sku", sku)
if (!row) {
console.log(`SKU '${sku}' not found in sheet. Cannot create folder.`)
return
}
const photosCell = getCellRangeByColumnName(
productInventorySheet,
"photos",
row
)
const folderUrl = photosCell.getRichTextValue().getLinkUrl()
const folderUrl = sheetService.getCellHyperlink("product_inventory", row, "photos")
console.log(`Folder URL from cell: ${folderUrl}`)
if (folderUrl && folderUrl.includes("drive.google.com")) {
@ -429,9 +411,5 @@ export function createPhotoFolderForSku(config: Config, sku: string) {
let url = newFolder.getUrl()
console.log(`Folder URL: ${url}`)
let linkValue = SpreadsheetApp.newRichTextValue()
.setText(folderName)
.setLinkUrl(url)
.build()
photosCell.setRichTextValue(linkValue)
sheetService.setCellHyperlink("product_inventory", row, "photos", folderName, url)
}

View File

@ -0,0 +1,9 @@
export interface ISpreadsheetService {
getHeaders(sheetName: string): string[];
getRowData(sheetName: string, row: number): any[];
getRowNumberByColumnValue(sheetName: string, columnName: string, value: any): number | undefined;
setCellValueByColumnName(sheetName: string, row: number, columnName: string, value: any): void;
getCellValueByColumnName(sheetName: string, row: number, columnName: string): any;
getCellHyperlink(sheetName: string, row: number, columnName: string): string | null;
setCellHyperlink(sheetName: string, row: number, columnName: string, displayText: string, url: string): void;
}

View File

@ -0,0 +1,85 @@
import { ISpreadsheetService } from "../interfaces/ISpreadsheetService";
export class GASSpreadsheetService implements ISpreadsheetService {
private getSheet(sheetName: string): GoogleAppsScript.Spreadsheet.Sheet {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);
if (!sheet) {
throw new Error(`Sheet '${sheetName}' not found`);
}
return sheet;
}
private getColumnIndex(sheet: GoogleAppsScript.Spreadsheet.Sheet, columnName: string): number {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
return headers.indexOf(columnName);
}
getHeaders(sheetName: string): string[] {
const sheet = this.getSheet(sheetName);
return sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
}
getRowData(sheetName: string, row: number): any[] {
const sheet = this.getSheet(sheetName);
const lastCol = sheet.getLastColumn();
// getRange(row, column, numRows, numColumns)
return sheet.getRange(row, 1, 1, lastCol).getValues()[0];
}
getRowNumberByColumnValue(sheetName: string, columnName: string, value: any): number | undefined {
const sheet = this.getSheet(sheetName);
const colIndex = this.getColumnIndex(sheet, columnName);
if (colIndex === -1) return undefined;
// Get all values in the column. Note: calling getValues() on a large sheet might be slow,
// but this matches the previous implementation's performance characteristics more or less.
// Ideally we would cache this or use a more efficient find.
// offset(1, colIndex) to skip header, but actually getColumnValuesByName usually gets everything including header or handles it.
// Original implementation: getColumnValuesByName gets range from row 2.
const data = sheet.getRange(2, colIndex + 1, sheet.getLastRow() - 1, 1).getValues();
const flatData = data.map(r => r[0]);
const index = flatData.indexOf(value);
if (index === -1) return undefined;
return index + 2; // +1 for 0-based index, +1 for header row
}
setCellValueByColumnName(sheetName: string, row: number, columnName: string, value: any): void {
const sheet = this.getSheet(sheetName);
const colIndex = this.getColumnIndex(sheet, columnName);
if (colIndex !== -1) {
sheet.getRange(row, colIndex + 1).setValue(value);
}
}
getCellValueByColumnName(sheetName: string, row: number, columnName: string): any {
const sheet = this.getSheet(sheetName);
const colIndex = this.getColumnIndex(sheet, columnName);
if (colIndex !== -1) {
return sheet.getRange(row, colIndex + 1).getValue();
}
return null;
}
getCellHyperlink(sheetName: string, row: number, columnName: string): string | null {
const sheet = this.getSheet(sheetName);
const colIndex = this.getColumnIndex(sheet, columnName);
if (colIndex !== -1) {
return sheet.getRange(row, colIndex + 1).getRichTextValue().getLinkUrl();
}
return null;
}
setCellHyperlink(sheetName: string, row: number, columnName: string, displayText: string, url: string): void {
const sheet = this.getSheet(sheetName);
const colIndex = this.getColumnIndex(sheet, columnName);
if (colIndex !== -1) {
const richText = SpreadsheetApp.newRichTextValue()
.setText(displayText)
.setLinkUrl(url)
.build();
sheet.getRange(row, colIndex + 1).setRichTextValue(richText);
}
}
}

View File

@ -0,0 +1,113 @@
import { ISpreadsheetService } from "../interfaces/ISpreadsheetService";
export class MockSpreadsheetService implements ISpreadsheetService {
// Store data as a map of sheetName -> array of rows (arrays of values)
// Row 0 is headers.
private sheets: Map<string, any[][]> = new Map();
constructor(initialData?: { [sheetName: string]: any[][] }) {
if (initialData) {
for (const [name, rows] of Object.entries(initialData)) {
this.sheets.set(name, JSON.parse(JSON.stringify(rows))); // Deep copy
}
}
}
setSheetData(sheetName: string, data: any[][]) {
this.sheets.set(sheetName, data);
}
private getSheet(sheetName: string): any[][] {
if (!this.sheets.has(sheetName)) {
// Create empty sheet with no headers if accessed but not defined?
// Or throw error to mimic GAS?
// Let's return empty array or throw.
throw new Error(`Sheet '${sheetName}' not found in mock`);
}
return this.sheets.get(sheetName)!;
}
getHeaders(sheetName: string): string[] {
const data = this.getSheet(sheetName);
if (data.length === 0) return [];
return data[0] as string[];
}
getRowData(sheetName: string, row: number): any[] {
const data = this.getSheet(sheetName);
// Row is 1-based index
if (row > data.length || row < 1) {
throw new Error(`Row ${row} out of bounds`);
}
return data[row - 1]; // Convert to 0-based
}
getRowNumberByColumnValue(sheetName: string, columnName: string, value: any): number | undefined {
const data = this.getSheet(sheetName);
if (data.length < 2) return undefined; // Only headers or empty
const headers = data[0];
const colIndex = headers.indexOf(columnName);
if (colIndex === -1) return undefined;
for (let i = 1; i < data.length; i++) {
if (data[i][colIndex] === value) {
return i + 1; // Convert 0-based index to 1-based row number
}
}
return undefined;
}
setCellValueByColumnName(sheetName: string, row: number, columnName: string, value: any): void {
const data = this.getSheet(sheetName);
const headers = data[0];
const colIndex = headers.indexOf(columnName);
if (colIndex === -1) {
throw new Error(`Column '${columnName}' not found`);
}
// Ensure row exists, extend if necessary (basic behavior)
while (data.length < row) {
data.push(new Array(headers.length).fill(""));
}
data[row - 1][colIndex] = value;
}
getCellValueByColumnName(sheetName: string, row: number, columnName: string): any {
const data = this.getSheet(sheetName);
const headers = data[0];
const colIndex = headers.indexOf(columnName);
if (colIndex === -1) return null;
if (row > data.length) return null;
return data[row - 1][colIndex];
}
// Helper to store links: key = "sheetName:row:colIndex", value = url
private links: Map<string, string> = new Map();
getCellHyperlink(sheetName: string, row: number, columnName: string): string | null {
const data = this.getSheet(sheetName);
const colIndex = data[0].indexOf(columnName);
if (colIndex === -1) return null;
const key = `${sheetName}:${row}:${colIndex}`;
return this.links.get(key) || null;
}
setCellHyperlink(sheetName: string, row: number, columnName: string, displayText: string, url: string): void {
// Set text value
this.setCellValueByColumnName(sheetName, row, columnName, displayText);
// Set link
const data = this.getSheet(sheetName);
const colIndex = data[0].indexOf(columnName);
if (colIndex !== -1) {
const key = `${sheetName}:${row}:${colIndex}`;
this.links.set(key, url);
}
}
}

5
src/sheetUtils.test.ts Normal file
View File

@ -0,0 +1,5 @@
describe('sheetUtils', () => {
it('should be able to run tests', () => {
expect(true).toBe(true);
});
});

33
src/test/setup.ts Normal file
View File

@ -0,0 +1,33 @@
// Mock global SpreadsheetApp to prevent crashes when importing legacy code
global.SpreadsheetApp = {
getActive: () => ({
getSheetByName: () => ({
getDataRange: () => ({
offset: () => ({
getValues: () => [],
clear: () => {},
}),
getValues: () => [],
}),
getLastRow: () => 0,
getRange: () => ({
getValues: () => [],
setValue: () => {},
setValues: () => {},
}),
}),
getSpreadsheetTimeZone: () => "GMT",
}),
getActiveSpreadsheet: () => ({
getSheetByName: () => null,
getSpreadsheetTimeZone: () => "GMT",
}),
} as any;
global.Logger = {
log: jest.fn(),
} as any;
global.Utilities = {
formatDate: () => "2025-01-01",
} as any;