feat: auto-create Draft Shopify products in Media Manager and fix description saving

- Implement auto-sync to Shopify: saving media for an unsynced product now creates it as a Draft in Shopify.
- Update Product.ts to default new items to DRAFT status.
- Allow getMediaSavePlan to run without a shopify_id (planning for new products).
- Fix description saving in mediaHandlers to reconcile 'body_html' and common variants.
- Sanitize empty quotes in MediaManager description textarea.
- Update mediaHandlers.test.ts to verify auto-creation behavior and fix mock pollution.
This commit is contained in:
Ben Miller
2026-01-03 12:01:55 -07:00
parent 778c0d1620
commit eeead33b2c
3 changed files with 70 additions and 19 deletions

View File

@ -198,6 +198,10 @@ export class Product {
"UpdateShopifyProduct: no product matched, this will be a new product"
)
newProduct = true
// Default to DRAFT for auto-created products
if (!this.shopify_status) {
this.shopify_status = "DRAFT";
}
}
console.log("UpdateShopifyProduct: calling productSet")
let sps = this.ToShopifyProductSet()

View File

@ -96,7 +96,9 @@ jest.mock("./Product", () => {
sku: sku,
shopify_id: "shopify_id_123",
title: "Test Product Title",
shopify_status: "ACTIVE",
MatchToShopifyProduct: jest.fn(),
UpdateShopifyProduct: jest.fn(),
ImportFromInventory: jest.fn()
}
})
@ -395,15 +397,20 @@ describe("mediaHandlers", () => {
expect(calledInstance.processMediaChanges).toHaveBeenCalledWith("SKU123", finalState, expect.anything(), null)
})
test("should throw if product not synced", () => {
test("saveMediaChanges should auto-create product if not synced", () => {
const MockProduct = Product as unknown as jest.Mock
const mockUpdateShopify = jest.fn().mockImplementation(function(this: any) {
this.shopify_id = "NEW_ID"
})
MockProduct.mockImplementationOnce(() => ({
shopify_id: null,
MatchToShopifyProduct: jest.fn(),
UpdateShopifyProduct: mockUpdateShopify,
ImportFromInventory: jest.fn()
}))
expect(() => saveMediaChanges("SKU123", [])).toThrow("Product must be synced")
saveMediaChanges("SKU123", [])
expect(mockUpdateShopify).toHaveBeenCalled()
})
test("should update sheet thumbnail with first image", () => {
@ -592,9 +599,11 @@ describe("mediaHandlers", () => {
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
const mockSSInstance = {
setCellValueByColumnName: jest.fn()
setCellValueByColumnName: jest.fn(),
getRowNumberByColumnValue: jest.fn().mockReturnValue(5), // Added for robustness
getHeaders: jest.fn().mockReturnValue(["sku", "title", "product_type", "product_style", "body_html"])
};
(GASSpreadsheetService as unknown as jest.Mock).mockReturnValue(mockSSInstance);
(GASSpreadsheetService as unknown as jest.Mock).mockReturnValueOnce(mockSSInstance);
(newSku as jest.Mock).mockReturnValue("SKU-123");
@ -608,6 +617,37 @@ describe("mediaHandlers", () => {
expect(result).toBe("SKU-123");
})
test("saveMediaChanges should auto-create product if unsynced", () => {
// Mock defaults for this test
const mockRange = { getRow: () => 5 };
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),
getActiveRange: jest.fn().mockReturnValue(mockRange),
getLastColumn: jest.fn().mockReturnValue(5),
getRange: jest.fn().mockReturnValue(mockRange)
};
(global.SpreadsheetApp.getActiveSheet as jest.Mock).mockReturnValue(mockSheet);
// Setup Unsynced Product
const MockProduct = Product as unknown as jest.Mock
const mockUpdateShopify = jest.fn().mockImplementation(function(this: any) {
this.shopify_id = "CREATED_ID_123"
this.shopify_status = "DRAFT"
})
MockProduct.mockImplementationOnce(() => ({
shopify_id: "",
MatchToShopifyProduct: jest.fn(),
UpdateShopifyProduct: mockUpdateShopify
}))
// Proceed with save
const finalState = [{ id: "1" }]
saveMediaChanges("SKU_NEW", finalState)
expect(mockUpdateShopify).toHaveBeenCalled()
})
test("generateSkuForActiveRow should delegate to newSku", () => {
const mockSheet = {
getName: jest.fn().mockReturnValue("product_inventory"),

View File

@ -141,10 +141,12 @@ export function saveMediaChanges(sku: string, finalState: any[], jobId: string |
}
if (!product.shopify_id) {
// Allow saving Drive-only changes? No, we need Shopify context for "Staging" usually.
// But if we just rename drive files, we could?
// For now, fail safe.
throw new Error("Product must be synced to Shopify before saving media changes.")
console.log("saveMediaChanges: Product not synced. Auto-creating Draft Product...");
product.UpdateShopifyProduct(shop);
if (!product.shopify_id) {
throw new Error("Failed to auto-create Draft Product. Cannot save media.");
}
}
const logs = mediaService.processMediaChanges(sku, finalState, product.shopify_id, jobId)
@ -252,10 +254,11 @@ export function getMediaSavePlan(sku: string, finalState: any[]) {
}
if (!product.shopify_id) {
throw new Error("Product must be synced to Shopify before saving media changes.")
console.log("getMediaSavePlan: Product not synced. Proceeding with empty Shopify state.");
}
return mediaService.calculatePlan(sku, finalState, product.shopify_id);
// Pass empty string if no ID, ensure calculatePlan handles it (it expects string)
return mediaService.calculatePlan(sku, finalState, product.shopify_id || "");
}
export function executeSavePhase(sku: string, phase: string, planData: any, jobId: string | null = null) {
@ -274,7 +277,9 @@ export function executeSavePhase(sku: string, phase: string, planData: any, jobI
}
if (!product.shopify_id) {
throw new Error("Product must be synced to Shopify before saving media changes.")
console.log("executeSavePhase: Product not synced. Auto-creating Draft Product...");
product.UpdateShopifyProduct(shop);
if (!product.shopify_id) throw new Error("Failed to auto-create Draft Product.");
}
return mediaService.executeSavePhase(sku, phase, planData, product.shopify_id, jobId);
@ -296,7 +301,9 @@ export function executeFullSavePlan(sku: string, plan: any, jobId: string | null
}
if (!product.shopify_id) {
throw new Error("Product must be synced to Shopify before saving media changes.")
console.log("executeFullSavePlan: Product not synced. Auto-creating Draft Product...");
product.UpdateShopifyProduct(shop);
if (!product.shopify_id) throw new Error("Failed to auto-create Draft Product.");
}
return mediaService.executeFullSavePlan(sku, plan, product.shopify_id, jobId);