Compare commits
3 Commits
3a184154db
...
2417359595
| Author | SHA1 | Date | |
|---|---|---|---|
| 2417359595 | |||
| 7cb469ccf9 | |||
| 2672d47203 |
@ -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.
|
||||||
|
|||||||
@ -43,8 +43,15 @@ The system allows you to:
|
|||||||
Run unit tests using Jest:
|
Run unit tests using Jest:
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Code Quality Enforcement
|
||||||
|
This project uses **Husky** to enforce code quality locally.
|
||||||
|
- **Pre-commit Hook**: Runs tests on changed files before every commit.
|
||||||
|
- **Coverage Requirement**: Modified files must maintain **80% code coverage**. Commits will be blocked if this threshold is not met.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `src/`: Source code (TypeScript)
|
- `src/`: Source code (TypeScript)
|
||||||
|
|||||||
@ -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`)
|
||||||
|
|
||||||
@ -87,13 +101,20 @@ A dedicated side panel provides visibility into the background queue system.
|
|||||||
- Provides controls to globally enable/disable processing.
|
- Provides controls to globally enable/disable processing.
|
||||||
- Allows manual intervention (delete/push) for individual items.
|
- Allows manual intervention (delete/push) for individual items.
|
||||||
|
|
||||||
### 6. Service Layer & Testing
|
### 6. Service Layer, Testing & Quality
|
||||||
|
|
||||||
To enable unit testing without Google Apps Script dependencies, the project uses a Service pattern with Dependency Injection.
|
To enable unit testing without Google Apps Script dependencies, the project uses a Service pattern with Dependency Injection.
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
- **`ISpreadsheetService`**: Interface for all sheet interactions.
|
- **`ISpreadsheetService`**: Interface for all sheet interactions.
|
||||||
- **`GASSpreadsheetService`**: Production implementation wrapping `SpreadsheetApp`.
|
- **`GASSpreadsheetService`**: Production implementation wrapping `SpreadsheetApp`.
|
||||||
- **`MockSpreadsheetService`**: In-memory implementation for tests.
|
- **`MockSpreadsheetService`**: In-memory implementation for tests.
|
||||||
|
|
||||||
|
#### Quality Assurance
|
||||||
|
We use **Husky** and **lint-staged** to enforce quality standards at the commit level:
|
||||||
|
1. **Pre-commit Hook**: Automatically runs `npm test -- --onlyChanged --coverage`.
|
||||||
|
2. **Coverage Policy**: Any file modified in a commit must meet an **80% line coverage** threshold. This ensures the codebase quality improves monotonically ("Boy Scout Rule").
|
||||||
|
|
||||||
|
|
||||||
Classes (like `Product`) should accept an `ISpreadsheetService` in their constructor. This allows providing the Mock service during tests to verify logic without touching real Google Sheets.
|
Classes (like `Product`) should accept an `ISpreadsheetService` in their constructor. This allows providing the Mock service during tests to verify logic without touching real Google Sheets.
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,30 @@
|
|||||||
import { Product } from "./Product";
|
import { Product } from "./Product";
|
||||||
import { MockSpreadsheetService } from "./services/MockSpreadsheetService";
|
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", () => {
|
describe("Product", () => {
|
||||||
let mockService: MockSpreadsheetService;
|
let mockService: MockSpreadsheetService;
|
||||||
|
let mockShop: MockShop;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setup mock data
|
// Setup mock data
|
||||||
@ -16,6 +38,7 @@ describe("Product", () => {
|
|||||||
productData
|
productData
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
mockShop = new MockShop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should load data from inventory sheet using service", () => {
|
it("should load data from inventory sheet using service", () => {
|
||||||
@ -32,4 +55,167 @@ describe("Product", () => {
|
|||||||
new Product("NON-EXISTENT-SKU", mockService);
|
new Product("NON-EXISTENT-SKU", mockService);
|
||||||
}).toThrow("product sku 'NON-EXISTENT-SKU' not found in product_inventory");
|
}).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", "<p>Desc</p>", "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("<p>Desc</p>");
|
||||||
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -81,7 +81,8 @@ export class MockSpreadsheetService implements ISpreadsheetService {
|
|||||||
const colIndex = headers.indexOf(columnName);
|
const colIndex = headers.indexOf(columnName);
|
||||||
|
|
||||||
if (colIndex === -1) return null;
|
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];
|
return data[row - 1][colIndex];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
53
src/test/MockShop.ts
Normal file
53
src/test/MockShop.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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 setInventoryItemCalledWith: 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; }
|
||||||
|
}
|
||||||
@ -31,3 +31,15 @@ global.Logger = {
|
|||||||
global.Utilities = {
|
global.Utilities = {
|
||||||
formatDate: () => "2025-01-01",
|
formatDate: () => "2025-01-01",
|
||||||
} as any;
|
} 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;
|
||||||
|
|||||||
BIN
test_output.txt
Normal file
BIN
test_output.txt
Normal file
Binary file not shown.
Reference in New Issue
Block a user