diff --git a/src/Product.test.ts b/src/Product.test.ts index 847d926..597d59a 100644 --- a/src/Product.test.ts +++ b/src/Product.test.ts @@ -1,8 +1,30 @@ import { Product } from "./Product"; import { MockSpreadsheetService } from "./services/MockSpreadsheetService"; +import { MockShop } from "./test/MockShop"; + +// Mock Config class to avoid GAS usage +jest.mock("./config", () => { + return { + Config: jest.fn().mockImplementation(() => { + return { + productPhotosFolderId: "mock-folder-id", + shopifyApiKey: "mock-key", + shopifyApiSecretKey: "mock-secret", + shopifyAdminApiAccessToken: "mock-token", + shopifyApiURI: "mock-uri", + shopifyStorePublicationId: "mock-pub-id", + shopifyLocationId: "mock-loc-id", + shopifyCountryCodeOfOrigin: "US", + shopifyProvinceCodeOfOrigin: "CA", + salesSyncFrequency: 10 + }; + }) + }; +}); describe("Product", () => { let mockService: MockSpreadsheetService; + let mockShop: MockShop; beforeEach(() => { // Setup mock data @@ -16,6 +38,7 @@ describe("Product", () => { productData ] }); + mockShop = new MockShop(); }); it("should load data from inventory sheet using service", () => { @@ -32,4 +55,167 @@ describe("Product", () => { new Product("NON-EXISTENT-SKU", mockService); }).toThrow("product sku 'NON-EXISTENT-SKU' not found in product_inventory"); }); + + it("should skip placeholder values (?)", () => { + mockService.setSheetData("product_inventory", [ + ["sku", "title", "price"], + ["TEST-SKU-2", "Original Title", 20.00] + ]); + const product = new Product("TEST-SKU-2", mockService); + + // Simulate edit with placeholder + mockService.setSheetData("product_inventory", [ + ["sku", "title", "price"], + ["TEST-SKU-2", "?", 25.00] + ]); + + // Create new instance to trigger import + const updatedProduct = new Product("TEST-SKU-2", mockService); + + // Title should be empty (default) because (?) means skip, + // BUT Product.ts logic says: if value is "?", continue. + // The Product class properties are initialized to defaults (e.g. title=""). + // If import finds "?", it continues loop, so title remains "". + expect(updatedProduct.title).toBe(""); + expect(updatedProduct.price).toBe(25.00); + }); + + it("should ignore properties not in class", () => { + mockService.setSheetData("product_inventory", [ + ["sku", "title", "unknown_col"], + ["TEST-SKU-3", "Title 3", "some value"] + ]); + const product = new Product("TEST-SKU-3", mockService); + expect((product as any).unknown_col).toBeUndefined(); + expect(product.title).toBe("Title 3"); + }); + it("should map fields correctly to ShopifyProductSet", () => { + mockService.setSheetData("product_inventory", [ + ["sku", "title", "tags", "description", "product_type", "price", "compare_at_price"], + ["TEST-SKU-4", "Mapper Test", "tag1, tag2", "
Desc
", "Type B", 50.00, 60.00] + ]); + mockService.setSheetData("values", [ + ["product_type", "shopify_category", "ebay_category_id"], + ["Type B", "Shopify Cat B", "Ebay Cat B"] + ]); + + const product = new Product("TEST-SKU-4", mockService); + product.shopify_status = "ACTIVE"; // Simulate status set + + const sps = product.ToShopifyProductSet(); + + expect(sps.title).toBe("Mapper Test"); + expect(sps.tags).toBe("tag1, tag2"); + expect(sps.descriptionHtml).toBe("Desc
"); + expect(sps.productType).toBe("Type B"); + expect(sps.category).toBe("Shopify Cat B"); + expect(sps.status).toBe("ACTIVE"); + expect((sps as any).handle).toBe("TEST-SKU-4"); + + expect(sps.variants[0].price).toBe(50.00); + expect(sps.variants[0].compareAtPrice).toBe(60.00); + }); + + it("should create new product in Shopify if ID not present", () => { + // Setup data + mockService.setSheetData("product_inventory", [ + ["sku", "title", "weight_grams", "shopify_id", "product_type", "photos"], + ["TEST-NEW-1", "New Prod", "500", "", "Type A", ""] + ]); + + // Mock Config + // Note: Config is instantiated inside Product.ts using new Config(). + // Since we cannot mock the constructor easily without DI, and Product.ts imports Config directly, + // we rely on the fact that Config reads from sheet "vars". + mockService.setSheetData("vars", [ + ["key", "value"], + ["shopifyLocationId", "loc_123"], + ["shopifyCountryCodeOfOrigin", "US"], + ["shopifyProvinceCodeOfOrigin", "CA"] + ]); + mockService.setSheetData("values", [ + ["product_type", "shopify_category", "ebay_category_id"], + ["Type A", "Category A", "123"] + ]); + + const product = new Product("TEST-NEW-1", mockService); + + // Mock Shop responses + // 1. MatchToShopifyProduct (GetProductBySku) -> returns empty/null + mockShop.mockProductBySku = {}; + + // 2. productSet mutation + // already mocked in MockShop.shopifyGraphQLAPI to return { product: { id: "mock-new-id" } } + + // 3. GetInventoryItemBySku loop + // Needs to return item with ID. + // MockShop.GetInventoryItemBySku returns { id: "mock-inv-id" } + + product.UpdateShopifyProduct(mockShop); + + // Verify interactions + // 1. Match called (GetProductBySku) + expect(mockShop.getProductBySkuCalledWith).toBe("TEST-NEW-1"); + + // 2. productSet called + expect(mockShop.productSetCalledWith).toBeTruthy(); + const sps = mockShop.productSetCalledWith.variables.productSet; + expect(sps.title).toBe("New Prod"); + expect(sps.handle).toBe("TEST-NEW-1"); + + // 3. shopify_id updated in object and sheet + expect(product.shopify_id).toBe("mock-new-id"); + const updatedId = mockService.getCellValueByColumnName("product_inventory", 2, "shopify_id"); + expect(updatedId).toBe("mock-new-id"); + }); + + it("should update existing product using ID", () => { + // Setup data with ID + mockService.setSheetData("product_inventory", [ + ["sku", "title", "weight_grams", "shopify_id", "product_type", "photos"], + ["TEST-EXISTING-1", "Updated Title", "1000", "123456", "Type A", ""] + ]); + mockService.setSheetData("vars", [ + ["key", "value"], + ["shopifyLocationId", "loc_123"], + ["shopifyCountryCodeOfOrigin", "US"], + ["shopifyProvinceCodeOfOrigin", "CA"] + ]); + mockService.setSheetData("values", [ + ["product_type", "shopify_category", "ebay_category_id"], + ["Type A", "Category A", "123"] + ]); + + const product = new Product("TEST-EXISTING-1", mockService); + + // Mock Shop Match + // MatchToShopifyProduct called -> check ID -> GetProductById + mockShop.mockProductById = { + id: "123456", + title: "Old Title", + variants: { nodes: [{ id: "gid://shopify/ProductVariant/123456", sku: "TEST-EXISTING-1" }] }, + options: [{ id: "opt1", optionValues: [{ id: "optval1" }] }] + }; + + // Mock Shop Update + // productSet mutation + mockShop.mockResponse = { + productSet: { + product: { id: "123456" } + } + }; + // GetInventoryItem -> returns item + mockShop.setInventoryItemCalledWith = null; // spy + + product.UpdateShopifyProduct(mockShop); + + // Verify Match Used ID + expect(mockShop.getProductByIdCalledWith).toBe("123456"); + + // Verify Update Payload + expect(mockShop.productSetCalledWith).toBeTruthy(); + const sps = mockShop.productSetCalledWith.variables.productSet; + expect(sps.title).toBe("Updated Title"); // Should use new title from sheet + expect(sps.id).toBe("123456"); // Should include ID to update + }); }); diff --git a/src/services/MockSpreadsheetService.ts b/src/services/MockSpreadsheetService.ts index 988925e..08809db 100644 --- a/src/services/MockSpreadsheetService.ts +++ b/src/services/MockSpreadsheetService.ts @@ -81,7 +81,8 @@ export class MockSpreadsheetService implements ISpreadsheetService { const colIndex = headers.indexOf(columnName); if (colIndex === -1) return null; - if (row > data.length) return null; + if (colIndex === -1) return null; + if (row > data.length || row < 1) return null; return data[row - 1][colIndex]; } diff --git a/src/test/MockShop.ts b/src/test/MockShop.ts index 04c9d40..7a43910 100644 --- a/src/test/MockShop.ts +++ b/src/test/MockShop.ts @@ -8,6 +8,7 @@ export class MockShop implements IShop { public getProductByIdCalledWith: string | null = null; public productSetCalledWith: any | null = null; public shopifyGraphQLAPICalledWith: any | null = null; + public setInventoryItemCalledWith: any | null = null; public mockProductBySku: any = null; public mockProductById: any = null; diff --git a/src/test/setup.ts b/src/test/setup.ts index 3f953f1..e9d2f12 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -31,3 +31,15 @@ global.Logger = { global.Utilities = { formatDate: () => "2025-01-01", } as any; + +global.DriveApp = { + getFolderById: (id: string) => ({ + getFoldersByName: (name: string) => ({ + hasNext: () => false, + next: () => ({ getUrl: () => "http://mock-drive-url" }) + }), + createFolder: (name: string) => ({ + getUrl: () => "http://mock-drive-url" + }), + }), +} as any;