diff --git a/.gitignore b/.gitignore index 55c339d..be31f0c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ desktop.ini .continue/** .clasp.json coverage/ -test_output.txt +test_*.txt .agent/ diff --git a/src/mediaHandlers.test.ts b/src/mediaHandlers.test.ts index 64c58f1..b708903 100644 --- a/src/mediaHandlers.test.ts +++ b/src/mediaHandlers.test.ts @@ -23,8 +23,16 @@ jest.mock("./config", () => { jest.mock("./services/GASNetworkService") jest.mock("./services/ShopifyMediaService") jest.mock("./shopifyApi", () => ({ Shop: jest.fn() })) -jest.mock("./services/MediaService") -jest.mock("./Product", () => ({ Product: jest.fn().mockImplementation(() => ({ shopify_id: "123", MatchToShopifyProduct: jest.fn() })) })) +jest.mock("./services/MediaService", () => { + return { + MediaService: jest.fn().mockImplementation(() => { + return { + getUnifiedMediaState: jest.fn().mockReturnValue([]), + processMediaChanges: jest.fn().mockReturnValue([]) + } + }) + } +}) // Mock GASDriveService @@ -49,10 +57,30 @@ jest.mock("./services/GASSpreadsheetService", () => { GASSpreadsheetService: jest.fn().mockImplementation(() => { return { getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => { + // console.log(`Mock GASSpreadsheetService getCellValueByColumnName called: ${col}`); if (col === "sku") return "TEST-SKU" if (col === "title") return "Test Product Title" 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"), 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 // UrlFetchApp @@ -132,10 +166,17 @@ global.Session = { global.HtmlService = { createHtmlOutputFromFile: jest.fn().mockReturnValue({ setTitle: jest.fn().mockReturnThis(), - setWidth: jest.fn().mockReturnThis() + setWidth: jest.fn().mockReturnThis(), + setHeight: jest.fn().mockReturnThis() }) } as any +// MimeType +global.MimeType = { + JPEG: "image/jpeg", + PNG: "image/png" +} as any + describe("mediaHandlers", () => { beforeEach(() => { @@ -249,7 +290,8 @@ describe("mediaHandlers", () => { // Get the instance that was created 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 expect(mockInstance.getUnifiedMediaState).toHaveBeenCalledWith("SKU123", expect.anything()) @@ -263,16 +305,56 @@ describe("mediaHandlers", () => { saveMediaChanges("SKU123", finalState) 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()) }) test("should throw if product not synced", () => { - const { Product } = require("./Product") - Product.mockImplementationOnce(() => ({ shopify_id: null, MatchToShopifyProduct: jest.fn() })) + const MockProduct = Product as unknown as jest.Mock + MockProduct.mockImplementationOnce(() => ({ + shopify_id: null, + MatchToShopifyProduct: jest.fn(), + ImportFromInventory: jest.fn() + })) 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", () => { diff --git a/src/mediaHandlers.ts b/src/mediaHandlers.ts index 8bcdcbc..7ab9c10 100644 --- a/src/mediaHandlers.ts +++ b/src/mediaHandlers.ts @@ -84,7 +84,54 @@ export function saveMediaChanges(sku: string, finalState: any[]) { 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 }