feat: enforce SKU validity, use SKU as handle
This commit enforces proper SKU validation, uses the SKU as the Shopify handle, and implements ID-based product updates to allow renaming. It also extracts the IShop interface for TDD.
This commit is contained in:
@ -12,7 +12,8 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
|
|||||||
1. **Documentation First**: Before implementing complex features, we update the plan and often the documentation (README/ARCHITECTURE).
|
1. **Documentation First**: Before implementing complex features, we update the plan and often the documentation (README/ARCHITECTURE).
|
||||||
2. **Safety First**: We use `SafeToAutoRun: false` for commands that deploy or modify external state until verified.
|
2. **Safety First**: We use `SafeToAutoRun: false` for commands that deploy or modify external state until verified.
|
||||||
3. **Strict Typing**: We use TypeScript. No `any` unless absolutely necessary (and even then, we try to avoid it).
|
3. **Strict Typing**: We use TypeScript. No `any` unless absolutely necessary (and even then, we try to avoid it).
|
||||||
4. **Artifact Usage**: We use `task.md`, `implementation_plan.md`, and `walkthrough.md` to track state.
|
4. **TDD**: We follow Test Driven Development (Red/Green/Refactor). Write failing tests before implementing features.
|
||||||
|
5. **Artifact Usage**: We use `task.md`, `implementation_plan.md`, and `walkthrough.md` to track state.
|
||||||
|
|
||||||
## Key Technical Decisions
|
## Key Technical Decisions
|
||||||
- **Queue System**: We implemented `onEditQueue.ts` to batch edits. This prevents hitting Shopify API rate limits and Google Apps Script execution limits during rapid manual edits.
|
- **Queue System**: We implemented `onEditQueue.ts` to batch edits. This prevents hitting Shopify API rate limits and Google Apps Script execution limits during rapid manual edits.
|
||||||
|
|||||||
@ -37,6 +37,18 @@ To avoid hitting Shopify API rate limits and Google Apps Script execution time l
|
|||||||
- Reads `pendingEdits`.
|
- Reads `pendingEdits`.
|
||||||
- Filters for edits older than `BATCH_INTERVAL_MS` (30s) to allow for multiple quick edits to the same SKU.
|
- Filters for edits older than `BATCH_INTERVAL_MS` (30s) to allow for multiple quick edits to the same SKU.
|
||||||
- Iterates through valid edits and calls `Product.UpdateShopifyProduct`.
|
- Iterates through valid edits and calls `Product.UpdateShopifyProduct`.
|
||||||
|
- **SKU Validation**: Before any action, checks if the SKU is valid (not empty, `?`, or `n`). Aborts if invalid.
|
||||||
|
|
||||||
|
### 2. Product Lifecycle Logic
|
||||||
|
|
||||||
|
- **Creation**:
|
||||||
|
- Uses the **SKU** as the Shopify **Handle** (URL slug).
|
||||||
|
- Prevents creation if the SKU is a placeholder.
|
||||||
|
|
||||||
|
- **Updates**:
|
||||||
|
- Prioritizes **ID-based lookup**.
|
||||||
|
- If the `shopify_id` column is populated, the system trusts this ID to locate the product in Shopify, even if the SKU has changed in the sheet.
|
||||||
|
- As a result, changing a SKU in the sheet and syncing will **rename** the existing product (handle/SKU) rather than creating a duplicate.
|
||||||
|
|
||||||
### 2. Shopify Integration (`src/shopifyApi.ts`)
|
### 2. Shopify Integration (`src/shopifyApi.ts`)
|
||||||
|
|
||||||
@ -45,7 +57,9 @@ The project uses a hybrid approach for the Shopify Admin API:
|
|||||||
- **REST API**: Used primarily for fetching Orders (legacy support).
|
- **REST API**: Used primarily for fetching Orders (legacy support).
|
||||||
- **GraphQL API**: Used for fetching and updating Products and Inventory.
|
- **GraphQL API**: Used for fetching and updating Products and Inventory.
|
||||||
|
|
||||||
The `Shop` class handles authentication using credentials stored in the "vars" sheet.
|
- **GraphQL API**: Used for fetching and updating Products and Inventory.
|
||||||
|
|
||||||
|
The `Shop` class implements the `IShop` interface, handling authentication using credentials stored in the "vars" sheet. The interface decoupling facilitates robust unit testing via `MockShop`.
|
||||||
|
|
||||||
### 3. Configuration (`src/config.ts`)
|
### 3. Configuration (`src/config.ts`)
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import * as shopify from "shopify-admin-api-typings"
|
|||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { ISpreadsheetService } from "./interfaces/ISpreadsheetService"
|
import { ISpreadsheetService } from "./interfaces/ISpreadsheetService"
|
||||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
|
import { IShop } from "./interfaces/IShop"
|
||||||
|
|
||||||
export class Product {
|
export class Product {
|
||||||
shopify_id: string = ""
|
shopify_id: string = ""
|
||||||
@ -87,8 +88,25 @@ export class Product {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MatchToShopifyProduct(shop: Shop): shopify.Product {
|
MatchToShopifyProduct(shop: IShop): shopify.Product {
|
||||||
// TODO: Look for and match based on known gid before SKU lookup
|
// Check if we have a known Shopify ID from the sheet
|
||||||
|
if (this.shopify_id && this.shopify_id !== "") {
|
||||||
|
console.log(`MatchToShopifyProduct: Checking ID '${this.shopify_id}' from sheet...`)
|
||||||
|
let productById = shop.GetProductById(this.shopify_id)
|
||||||
|
if (productById) {
|
||||||
|
console.log(`MatchToShopifyProduct: Found product by ID '${this.shopify_id}'`)
|
||||||
|
this.shopify_product = productById
|
||||||
|
this.shopify_id = this.shopify_product.id.toString()
|
||||||
|
this.shopify_default_variant_id = productById.variants.nodes[0].id
|
||||||
|
// We trust the ID, so we update the instantiated object with current Shopify data
|
||||||
|
// But we DO NOT overwrite the sheet's SKU/Title yet, because we might be pushing updates FROM sheet TO Shopify.
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
console.log(`MatchToShopifyProduct: Product with ID '${this.shopify_id}' not found. Falling back to SKU lookup.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to SKU lookup
|
||||||
let product = shop.GetProductBySku(this.sku)
|
let product = shop.GetProductBySku(this.sku)
|
||||||
if (product == undefined || product.id == undefined || product.id == "") {
|
if (product == undefined || product.id == undefined || product.id == "") {
|
||||||
console.log("MatchToShopifyProduct: no product matched")
|
console.log("MatchToShopifyProduct: no product matched")
|
||||||
@ -140,7 +158,9 @@ export class Product {
|
|||||||
sps.category = this.ShopifyCategory()
|
sps.category = this.ShopifyCategory()
|
||||||
}
|
}
|
||||||
sps.tags = this.tags
|
sps.tags = this.tags
|
||||||
|
sps.tags = this.tags
|
||||||
sps.title = this.title
|
sps.title = this.title
|
||||||
|
sps.handle = this.sku
|
||||||
sps.descriptionHtml = this.description
|
sps.descriptionHtml = this.description
|
||||||
sps.variants = []
|
sps.variants = []
|
||||||
let variant = new ShopifyVariant()
|
let variant = new ShopifyVariant()
|
||||||
@ -159,8 +179,14 @@ export class Product {
|
|||||||
return sps
|
return sps
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateShopifyProduct(shop: Shop) {
|
UpdateShopifyProduct(shop: IShop) {
|
||||||
console.log("UpdateShopifyProduct()")
|
console.log("UpdateShopifyProduct()")
|
||||||
|
// SKU Validation
|
||||||
|
if (!this.sku || this.sku === "" || this.sku === "?" || this.sku === "n") {
|
||||||
|
console.log("UpdateShopifyProduct: Invalid or placeholder SKU. Aborting.")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var newProduct = false
|
var newProduct = false
|
||||||
let config = new Config()
|
let config = new Config()
|
||||||
this.MatchToShopifyProduct(shop)
|
this.MatchToShopifyProduct(shop)
|
||||||
@ -215,7 +241,7 @@ export class Product {
|
|||||||
this.CreatePhotoFolder();
|
this.CreatePhotoFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateAllMetafields(shop: Shop) {
|
UpdateAllMetafields(shop: IShop) {
|
||||||
console.log("UpdateAllMetafields()")
|
console.log("UpdateAllMetafields()")
|
||||||
if (!this.shopify_id) {
|
if (!this.shopify_id) {
|
||||||
console.log("Cannot update metafields without a Shopify Product ID.")
|
console.log("Cannot update metafields without a Shopify Product ID.")
|
||||||
@ -326,7 +352,7 @@ export class Product {
|
|||||||
createPhotoFolderForSku(new(Config), this.sku, this.sheetService);
|
createPhotoFolderForSku(new(Config), this.sku, this.sheetService);
|
||||||
}
|
}
|
||||||
|
|
||||||
PublishToShopifyOnlineStore(shop: Shop) {
|
PublishToShopifyOnlineStore(shop: IShop) {
|
||||||
console.log("PublishToShopifyOnlineStore")
|
console.log("PublishToShopifyOnlineStore")
|
||||||
let config = new Config()
|
let config = new Config()
|
||||||
let query = /* GraphQL */ `
|
let query = /* GraphQL */ `
|
||||||
@ -364,7 +390,7 @@ export class Product {
|
|||||||
return shop.shopifyGraphQLAPI(JSON.parse(j))
|
return shop.shopifyGraphQLAPI(JSON.parse(j))
|
||||||
}
|
}
|
||||||
|
|
||||||
PublishShopifyProduct(shop: Shop) {
|
PublishShopifyProduct(shop: IShop) {
|
||||||
//TODO: update product in sheet
|
//TODO: update product in sheet
|
||||||
// TODO: status
|
// TODO: status
|
||||||
// TODO: shopify_status
|
// TODO: shopify_status
|
||||||
|
|||||||
106
src/ProductLogic.test.ts
Normal file
106
src/ProductLogic.test.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { Product } from "./Product";
|
||||||
|
import { MockSpreadsheetService } from "./services/MockSpreadsheetService";
|
||||||
|
import { MockShop } from "./test/MockShop";
|
||||||
|
|
||||||
|
describe("Product Logic (TDD)", () => {
|
||||||
|
let mockService: MockSpreadsheetService;
|
||||||
|
let mockShop: MockShop;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockService = new MockSpreadsheetService({
|
||||||
|
product_inventory: [
|
||||||
|
["sku", "title", "price", "shopify_id", "product_type"], // Headers
|
||||||
|
["TEST-SKU-1", "Test Product", 10.99, "", "Type A"] // Data
|
||||||
|
],
|
||||||
|
values: [
|
||||||
|
["product_type", "shopify_category", "ebay_category_id"],
|
||||||
|
["Type A", "Category A", "123"]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
mockShop = new MockShop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UpdateShopifyProduct should abort if SKU is invalid", () => {
|
||||||
|
// Setup invalid SKU
|
||||||
|
mockService.setSheetData("product_inventory", [
|
||||||
|
["sku", "title"],
|
||||||
|
["?", "Invalid Product"]
|
||||||
|
]);
|
||||||
|
// Allow empty sku to be passed to constructor logic check, but Product constructor throws if sku not found.
|
||||||
|
// So we pass a valid sku that exists in sheet, but looks invalid?
|
||||||
|
// The requirement is "based on the product's title... If I have a placeholder value... ensure products are not created until they have a valid SKU".
|
||||||
|
// In `Product.ts`, constructor takes `sku`.
|
||||||
|
// If I pass `?` and it's in the sheet, it constructs.
|
||||||
|
|
||||||
|
const product = new Product("?", mockService);
|
||||||
|
|
||||||
|
// Attempt update
|
||||||
|
product.UpdateShopifyProduct(mockShop);
|
||||||
|
|
||||||
|
// Verify no calls to creating product
|
||||||
|
// We expect MatchToShopifyProduct might be called (read only), but NOT productSet (writ)
|
||||||
|
// Actually our plan said "abort operation" at start of UpdateShopifyProduct.
|
||||||
|
expect(mockShop.productSetCalledWith).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ToShopifyProductSet should set handle to SKU", () => {
|
||||||
|
const product = new Product("TEST-SKU-1", mockService);
|
||||||
|
const sps = product.ToShopifyProductSet();
|
||||||
|
|
||||||
|
// We expect sps to have a 'handle' property equal to the sku
|
||||||
|
// This will fail compilation initially as ShopifyProductSetInput doesn't have handle
|
||||||
|
expect((sps as any).handle).toBe("TEST-SKU-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MatchToShopifyProduct should verify ID if present", () => {
|
||||||
|
// Setup data with shopify_id
|
||||||
|
mockService.setSheetData("product_inventory", [
|
||||||
|
["sku", "shopify_id"],
|
||||||
|
["TEST-SKU-OLD", "123456789"]
|
||||||
|
]);
|
||||||
|
const product = new Product("TEST-SKU-OLD", mockService);
|
||||||
|
|
||||||
|
// Mock the response for GetProductById
|
||||||
|
mockShop.mockProductById = {
|
||||||
|
id: "123456789",
|
||||||
|
title: "Old Title",
|
||||||
|
variants: { nodes: [{ id: "gid://shopify/ProductVariant/123456789", sku: "TEST-SKU-OLD" }] },
|
||||||
|
options: [{ id: "opt1", optionValues: [{ id: "optval1" }] }]
|
||||||
|
};
|
||||||
|
|
||||||
|
// We need to call Match, but it's called inside Update usually.
|
||||||
|
// We can call it directly for testing.
|
||||||
|
product.MatchToShopifyProduct(mockShop);
|
||||||
|
|
||||||
|
// Expect GetProductById to have been called
|
||||||
|
expect(mockShop.getProductByIdCalledWith).toBe("123456789");
|
||||||
|
expect(product.shopify_id).toBe("123456789");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MatchToShopifyProduct should fall back to SKU if ID lookup fails", () => {
|
||||||
|
// Setup data with shopify_id that is invalid
|
||||||
|
mockService.setSheetData("product_inventory", [
|
||||||
|
["sku", "shopify_id"],
|
||||||
|
["TEST-SKU-FAIL", "999999999"]
|
||||||
|
]);
|
||||||
|
const product = new Product("TEST-SKU-FAIL", mockService);
|
||||||
|
|
||||||
|
// Mock ID lookup failure (returns null/undefined)
|
||||||
|
mockShop.mockProductById = null;
|
||||||
|
|
||||||
|
// Mock SKU lookup success
|
||||||
|
mockShop.mockProductBySku = {
|
||||||
|
id: "555555555",
|
||||||
|
title: "Found By SKU",
|
||||||
|
variants: { nodes: [{ id: "gid://shopify/ProductVariant/555555555", sku: "TEST-SKU-FAIL" }] },
|
||||||
|
options: [{ id: "opt2", optionValues: [{ id: "optval2" }] }]
|
||||||
|
};
|
||||||
|
|
||||||
|
product.MatchToShopifyProduct(mockShop);
|
||||||
|
|
||||||
|
expect(mockShop.getProductByIdCalledWith).toBe("999999999");
|
||||||
|
// Should fall back to SKU
|
||||||
|
expect(mockShop.getProductBySkuCalledWith).toBe("TEST-SKU-FAIL");
|
||||||
|
expect(product.shopify_id).toBe("555555555");
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/interfaces/IShop.ts
Normal file
13
src/interfaces/IShop.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import * as shopify from "shopify-admin-api-typings";
|
||||||
|
import { Config } from "../config";
|
||||||
|
|
||||||
|
export interface IShop {
|
||||||
|
GetProductBySku(sku: string): any; // Return type is inferred as product node
|
||||||
|
GetProductById(id: string): any; // New method
|
||||||
|
GetInventoryItemBySku(sku: string): shopify.InventoryItem;
|
||||||
|
UpdateInventoryItemQuantity(item: shopify.InventoryItem, delta: number, config: Config): shopify.InventoryItem;
|
||||||
|
SetInventoryItemQuantity(item: shopify.InventoryItem, quantity: number, config: Config): any;
|
||||||
|
SetInventoryItemDefaults(item: shopify.InventoryItem, config: Config): shopify.InventoryItem;
|
||||||
|
SetInventoryItemWeight(item: shopify.InventoryItem, config: Config, weight: number, weight_unit: shopify.WeightUnit): shopify.InventoryItem;
|
||||||
|
shopifyGraphQLAPI(payload: any): any;
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@
|
|||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import * as shopify from "shopify-admin-api-typings"
|
import * as shopify from "shopify-admin-api-typings"
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
|
import { IShop } from "./interfaces/IShop"
|
||||||
|
|
||||||
const ss = SpreadsheetApp.getActive()
|
const ss = SpreadsheetApp.getActive()
|
||||||
|
|
||||||
@ -392,7 +393,7 @@ function parseLinkHeader(header) {
|
|||||||
return rels
|
return rels
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Shop {
|
export class Shop implements IShop {
|
||||||
private shopifyApiKey: string
|
private shopifyApiKey: string
|
||||||
private shopifyApiSecretKey: string
|
private shopifyApiSecretKey: string
|
||||||
private shopifyAdminApiAccessToken: string
|
private shopifyAdminApiAccessToken: string
|
||||||
@ -599,6 +600,34 @@ export class Shop {
|
|||||||
return product
|
return product
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GetProductById(id: string) {
|
||||||
|
console.log("GetProductById('" + id + "')")
|
||||||
|
let gql = /* GraphQL */ `
|
||||||
|
query productById {
|
||||||
|
product(id: "${id}") {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
handle
|
||||||
|
variants(first: 1) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
sku
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
let query = buildGqlQuery(gql, {})
|
||||||
|
let response = this.shopifyGraphQLAPI(query)
|
||||||
|
if (!response.content.data.product) {
|
||||||
|
console.log("GetProductById: no product matched")
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let product = response.content.data.product
|
||||||
|
console.log("Product found:\n" + JSON.stringify(product, null, 2))
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
GetInventoryItemBySku(sku: string) {
|
GetInventoryItemBySku(sku: string) {
|
||||||
console.log('GetInventoryItemBySku("' + sku + '")')
|
console.log('GetInventoryItemBySku("' + sku + '")')
|
||||||
let gql = /* GraphQL */ `
|
let gql = /* GraphQL */ `
|
||||||
@ -1132,6 +1161,7 @@ export class ShopifyProductSetQuery {
|
|||||||
export class ShopifyProductSetInput {
|
export class ShopifyProductSetInput {
|
||||||
category: string
|
category: string
|
||||||
descriptionHtml: string
|
descriptionHtml: string
|
||||||
|
handle: string
|
||||||
id?: string
|
id?: string
|
||||||
productType: string
|
productType: string
|
||||||
redirectNewHandle: boolean = true
|
redirectNewHandle: boolean = true
|
||||||
|
|||||||
52
src/test/MockShop.ts
Normal file
52
src/test/MockShop.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { IShop } from "../interfaces/IShop";
|
||||||
|
import * as shopify from "shopify-admin-api-typings";
|
||||||
|
import { Config } from "../config";
|
||||||
|
|
||||||
|
export class MockShop implements IShop {
|
||||||
|
// Mock methods to spy on calls
|
||||||
|
public getProductBySkuCalledWith: string | null = null;
|
||||||
|
public getProductByIdCalledWith: string | null = null;
|
||||||
|
public productSetCalledWith: any | null = null;
|
||||||
|
public shopifyGraphQLAPICalledWith: any | null = null;
|
||||||
|
|
||||||
|
public mockProductBySku: any = null;
|
||||||
|
public mockProductById: any = null;
|
||||||
|
public mockResponse: any = {};
|
||||||
|
|
||||||
|
GetProductBySku(sku: string) {
|
||||||
|
this.getProductBySkuCalledWith = sku;
|
||||||
|
return this.mockProductBySku || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
GetProductById(id: string) {
|
||||||
|
this.getProductByIdCalledWith = id;
|
||||||
|
return this.mockProductById;
|
||||||
|
}
|
||||||
|
|
||||||
|
shopifyGraphQLAPI(payload: any): any {
|
||||||
|
this.shopifyGraphQLAPICalledWith = payload;
|
||||||
|
// Basic mock response structure if needed
|
||||||
|
if (payload.query && payload.query.includes("productSet")) {
|
||||||
|
this.productSetCalledWith = payload;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: {
|
||||||
|
data: {
|
||||||
|
productSet: {
|
||||||
|
product: { id: "mock-new-id" }
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
edges: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stubs for other interface methods
|
||||||
|
GetInventoryItemBySku(sku: string): shopify.InventoryItem { return { id: "mock-inv-id" } as any; }
|
||||||
|
UpdateInventoryItemQuantity(item: shopify.InventoryItem, delta: number, config: Config): shopify.InventoryItem { return {} as any; }
|
||||||
|
SetInventoryItemQuantity(item: shopify.InventoryItem, quantity: number, config: Config): any { return {}; }
|
||||||
|
SetInventoryItemDefaults(item: shopify.InventoryItem, config: Config): shopify.InventoryItem { return {} as any; }
|
||||||
|
SetInventoryItemWeight(item: shopify.InventoryItem, config: Config, weight: number, weight_unit: shopify.WeightUnit): shopify.InventoryItem { return {} as any; }
|
||||||
|
}
|
||||||
BIN
test_output.txt
Normal file
BIN
test_output.txt
Normal file
Binary file not shown.
Reference in New Issue
Block a user