Refine media state handling and fix CellImageBuilder errors

- Update MediaService delegation tests in src/mediaHandlers.test.ts to use mock.results for more reliable instance retrieval.
- Fix CellImageBuilder failure in src/mediaHandlers.ts by using public Shopify thumbnail URLs for synced items and direct Drive thumbnail endpoints for non-synced items.
- Fallback to IMAGE() formula in the spreadsheet for Drive items to avoid authentication issues with native cell images.
- Add test_*.txt to .gitignore to keep the workspace clean.
- Ensure all tests pass with updated log expectations and mock data.
This commit is contained in:
Ben Miller
2025-12-31 04:21:46 -07:00
parent f25fb359e8
commit dc33390650
3 changed files with 140 additions and 11 deletions

2
.gitignore vendored
View File

@ -4,5 +4,5 @@ desktop.ini
.continue/** .continue/**
.clasp.json .clasp.json
coverage/ coverage/
test_output.txt test_*.txt
.agent/ .agent/

View File

@ -23,8 +23,16 @@ jest.mock("./config", () => {
jest.mock("./services/GASNetworkService") jest.mock("./services/GASNetworkService")
jest.mock("./services/ShopifyMediaService") jest.mock("./services/ShopifyMediaService")
jest.mock("./shopifyApi", () => ({ Shop: jest.fn() })) jest.mock("./shopifyApi", () => ({ Shop: jest.fn() }))
jest.mock("./services/MediaService") jest.mock("./services/MediaService", () => {
jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) })) return {
MediaService: jest.fn().mockImplementation(() => {
return {
getUnifiedMediaState: jest.fn().mockReturnValue([]),
processMediaChanges: jest.fn().mockReturnValue([])
}
})
}
})
// Mock GASDriveService // Mock GASDriveService
@ -49,11 +57,31 @@ jest.mock("./services/GASSpreadsheetService", () => {
GASSpreadsheetService: jest.fn().mockImplementation(() => { GASSpreadsheetService: jest.fn().mockImplementation(() => {
return { return {
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => { getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
// console.log(`Mock GASSpreadsheetService getCellValueByColumnName called: ${col}`);
if (col === "sku") return "TEST-SKU" if (col === "sku") return "TEST-SKU"
if (col === "title") return "Test Product Title" if (col === "title") return "Test Product Title"
return null return null
}),
getRowNumberByColumnValue: jest.fn().mockReturnValue(5),
setCellValueByColumnName: jest.fn(),
getHeaders: jest.fn().mockReturnValue(["sku", "title", "thumbnail"]),
getRowData: jest.fn()
}
}) })
} }
})
// Mock Product
jest.mock("./Product", () => {
return {
Product: jest.fn().mockImplementation((sku) => {
return {
sku: sku,
shopify_id: "shopify_id_123",
title: "Test Product Title",
MatchToShopifyProduct: jest.fn(),
ImportFromInventory: jest.fn()
}
}) })
} }
}) })
@ -88,7 +116,13 @@ global.SpreadsheetApp = {
getName: jest.fn().mockReturnValue("product_inventory"), getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 }) getActiveRange: jest.fn().mockReturnValue({ getRow: () => 2 })
}), }),
getActive: jest.fn() getActive: jest.fn(),
newCellImage: jest.fn().mockReturnValue({
setSourceUrl: jest.fn().mockReturnThis(),
setAltTextTitle: jest.fn().mockReturnThis(),
setAltTextDescription: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue("CELL_IMAGE_OBJECT")
})
} as any } as any
// UrlFetchApp // UrlFetchApp
@ -132,10 +166,17 @@ global.Session = {
global.HtmlService = { global.HtmlService = {
createHtmlOutputFromFile: jest.fn().mockReturnValue({ createHtmlOutputFromFile: jest.fn().mockReturnValue({
setTitle: jest.fn().mockReturnThis(), setTitle: jest.fn().mockReturnThis(),
setWidth: jest.fn().mockReturnThis() setWidth: jest.fn().mockReturnThis(),
setHeight: jest.fn().mockReturnThis()
}) })
} as any } as any
// MimeType
global.MimeType = {
JPEG: "image/jpeg",
PNG: "image/png"
} as any
describe("mediaHandlers", () => { describe("mediaHandlers", () => {
beforeEach(() => { beforeEach(() => {
@ -249,7 +290,8 @@ describe("mediaHandlers", () => {
// Get the instance that was created // Get the instance that was created
const MockMediaService = MediaService as unknown as jest.Mock const MockMediaService = MediaService as unknown as jest.Mock
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1] expect(MockMediaService).toHaveBeenCalled()
const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
// Checking delegation // Checking delegation
expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything()) expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything())
@ -263,16 +305,56 @@ describe("mediaHandlers", () => {
saveMediaChanges("SKU123", finalState) saveMediaChanges("SKU123", finalState)
const MockMediaService = MediaService as unknown as jest.Mock const MockMediaService = MediaService as unknown as jest.Mock
const mockInstance = MockMediaService.mock.instances[MockMediaService.mock.instances.length - 1] const mockInstance = MockMediaService.mock.results[MockMediaService.mock.results.length - 1].value
expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything()) expect(mockInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything())
}) })
test("should throw if product not synced", () => { test("should throw if product not synced", () => {
const { Product } = require("./Product") const MockProduct = Product as unknown as jest.Mock
Product.mockImplementationOnce(() => ({ shopify_id: null, MatchToShopifyProduct: jest.fn() })) MockProduct.mockImplementationOnce(() => ({
shopify_id: null,
MatchToShopifyProduct: jest.fn(),
ImportFromInventory: jest.fn()
}))
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced") expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
}) })
test("should update sheet thumbnail with first image", () => {
// Setup mock MediaService to NOT throw and just return logs
const MockMediaService = MediaService as unknown as jest.Mock
const mockGetUnifiedMediaState = jest.fn().mockReturnValue([
{ id: "2", driveId: "drive_file_2", galleryOrder: 1, contentUrl: "https://cdn.shopify.com/test.jpg", thumbnail: "https://cdn.shopify.com/test.jpg" }
])
MockMediaService.mockImplementation(() => ({
processMediaChanges: jest.fn().mockReturnValue(["Log 1"]),
getUnifiedMediaState: mockGetUnifiedMediaState
}))
const finalState = [
{ id: "1", driveId: "drive_file_1", galleryOrder: 10 },
{ id: "2", driveId: "drive_file_2", galleryOrder: 1 } // Should be first
]
const logs = saveMediaChanges("TEST-SKU", finalState)
expect(logs).toEqual(expect.arrayContaining([
expect.stringContaining("Updated sheet thumbnail")
]))
// Verify spreadsheet service interaction
const MockSpreadsheet = GASSpreadsheetService as unknown as jest.Mock
expect(MockSpreadsheet).toHaveBeenCalled()
const mockSS = MockSpreadsheet.mock.results[MockSpreadsheet.mock.results.length - 1].value
expect(mockSS.setCellValueByColumnName).toHaveBeenCalledWith(
"product_inventory",
5,
"thumbnail",
"CELL_IMAGE_OBJECT"
)
})
}) })
describe("Photo Session API", () => { describe("Photo Session API", () => {

View File

@ -84,7 +84,54 @@ export function saveMediaChanges(sku: string, finalState: any[]) {
throw new Error("Product must be synced to Shopify before saving media changes.") throw new Error("Product must be synced to Shopify before saving media changes.")
} }
return mediaService.processMediaChanges(sku, finalState, product.shopify_id) const logs = mediaService.processMediaChanges(sku, finalState, product.shopify_id)
// Update Sheet Thumbnail (Top of Gallery)
try {
// Refresh state to get Shopify CDN URLs
const latestState = mediaService.getUnifiedMediaState(sku, product.shopify_id);
const sorted = latestState.sort((a, b) => (a.galleryOrder || 0) - (b.galleryOrder || 0));
const firstItem = sorted[0];
if (firstItem) {
const ss = new GASSpreadsheetService();
const row = ss.getRowNumberByColumnValue("product_inventory", "sku", sku);
if (row) {
// Decide on the most reliable URL for the spreadsheet
// 1. If it's a synced Shopify item, use the Shopify preview image URL (public)
// 2. Otherwise (Drive item or adoption), use the dedicated Drive thumbnail endpoint
const isShopifyThumb = firstItem.thumbnail && firstItem.thumbnail.startsWith('http');
const driveThumbUrl = `https://drive.google.com/thumbnail?id=${firstItem.driveId}&sz=w400`;
const thumbUrl = isShopifyThumb ? firstItem.thumbnail : driveThumbUrl;
// Use CellImageBuilder for native in-cell image (Shopify only)
try {
// CellImageBuilder is picky about URLs and often fails with Drive's redirects/auth
// even if the file is public. Formula-based IMAGE() is more robust for Drive.
if (!isShopifyThumb) throw new Error("Use formula for Drive thumbnails");
const image = SpreadsheetApp.newCellImage()
.setSourceUrl(thumbUrl)
.setAltTextTitle(sku)
.setAltTextDescription(`Thumbnail for ${sku}`)
.build();
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", image);
logs.push(`Updated sheet thumbnail for SKU ${sku}`);
} catch (builderErr) {
// Fallback to formula
ss.setCellValueByColumnName("product_inventory", row, "thumbnail", `=IMAGE("${thumbUrl}")`);
logs.push(`Updated sheet thumbnail (Formula) for SKU ${sku}`);
}
} else {
logs.push(`Warning: Could not find row for SKU ${sku} to update thumbnail.`);
}
}
} catch (e) {
console.warn("Failed to update sheet thumbnail", e);
logs.push(`Warning: Failed to update sheet thumbnail: ${e.message}`);
}
return logs
} }