Initial commit: Complete Shopify Price Updater implementation
- Full Node.js application with Shopify GraphQL API integration - Compare At price support for promotional pricing - Comprehensive error handling and retry logic - Progress tracking with markdown logging - Complete test suite with unit and integration tests - Production-ready with proper exit codes and signal handling
This commit is contained in:
853
tests/services/product.test.js
Normal file
853
tests/services/product.test.js
Normal file
@@ -0,0 +1,853 @@
|
||||
const ProductService = require("../../src/services/product");
|
||||
const ShopifyService = require("../../src/services/shopify");
|
||||
const Logger = require("../../src/utils/logger");
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("../../src/services/shopify");
|
||||
jest.mock("../../src/utils/logger");
|
||||
|
||||
describe("ProductService Integration Tests", () => {
|
||||
let productService;
|
||||
let mockShopifyService;
|
||||
let mockLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock instances
|
||||
mockShopifyService = {
|
||||
executeQuery: jest.fn(),
|
||||
executeMutation: jest.fn(),
|
||||
executeWithRetry: jest.fn(),
|
||||
};
|
||||
|
||||
mockLogger = {
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
logProductUpdate: jest.fn(),
|
||||
logProductError: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock constructors
|
||||
ShopifyService.mockImplementation(() => mockShopifyService);
|
||||
Logger.mockImplementation(() => mockLogger);
|
||||
|
||||
productService = new ProductService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GraphQL Query Generation", () => {
|
||||
test("should generate correct products by tag query", () => {
|
||||
const query = productService.getProductsByTagQuery();
|
||||
|
||||
expect(query).toContain("query getProductsByTag");
|
||||
expect(query).toContain("$query: String!");
|
||||
expect(query).toContain("$first: Int!");
|
||||
expect(query).toContain("$after: String");
|
||||
expect(query).toContain(
|
||||
"products(first: $first, after: $after, query: $query)"
|
||||
);
|
||||
expect(query).toContain("variants(first: 100)");
|
||||
expect(query).toContain("pageInfo");
|
||||
expect(query).toContain("hasNextPage");
|
||||
expect(query).toContain("endCursor");
|
||||
});
|
||||
|
||||
test("should generate correct product variant update mutation", () => {
|
||||
const mutation = productService.getProductVariantUpdateMutation();
|
||||
|
||||
expect(mutation).toContain("mutation productVariantsBulkUpdate");
|
||||
expect(mutation).toContain("$productId: ID!");
|
||||
expect(mutation).toContain("$variants: [ProductVariantsBulkInput!]!");
|
||||
expect(mutation).toContain(
|
||||
"productVariantsBulkUpdate(productId: $productId, variants: $variants)"
|
||||
);
|
||||
expect(mutation).toContain("productVariant");
|
||||
expect(mutation).toContain("userErrors");
|
||||
expect(mutation).toContain("field");
|
||||
expect(mutation).toContain("message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Product Fetching with Pagination", () => {
|
||||
test("should fetch products with single page response", async () => {
|
||||
const mockResponse = {
|
||||
products: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Test Product 1",
|
||||
tags: ["test-tag", "sale"],
|
||||
variants: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: "29.99",
|
||||
compareAtPrice: null,
|
||||
title: "Default Title",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
|
||||
|
||||
const products = await productService.fetchProductsByTag("test-tag");
|
||||
|
||||
expect(products).toHaveLength(1);
|
||||
expect(products[0]).toEqual({
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Test Product 1",
|
||||
tags: ["test-tag", "sale"],
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: 29.99,
|
||||
compareAtPrice: null,
|
||||
title: "Default Title",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"Starting to fetch products with tag: test-tag"
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle multi-page responses with pagination", async () => {
|
||||
const firstPageResponse = {
|
||||
products: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product 1",
|
||||
tags: ["test-tag"],
|
||||
variants: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: "19.99",
|
||||
compareAtPrice: "24.99",
|
||||
title: "Variant 1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
endCursor: "eyJsYXN0X2lkIjoxMjN9",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const secondPageResponse = {
|
||||
products: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "gid://shopify/Product/789",
|
||||
title: "Product 2",
|
||||
tags: ["test-tag"],
|
||||
variants: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "gid://shopify/ProductVariant/101112",
|
||||
price: "39.99",
|
||||
compareAtPrice: null,
|
||||
title: "Variant 2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockShopifyService.executeWithRetry
|
||||
.mockResolvedValueOnce(firstPageResponse)
|
||||
.mockResolvedValueOnce(secondPageResponse);
|
||||
|
||||
const products = await productService.fetchProductsByTag("test-tag");
|
||||
|
||||
expect(products).toHaveLength(2);
|
||||
expect(products[0].id).toBe("gid://shopify/Product/123");
|
||||
expect(products[1].id).toBe("gid://shopify/Product/789");
|
||||
|
||||
expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check that pagination variables were passed correctly
|
||||
const firstCall = mockShopifyService.executeWithRetry.mock.calls[0][0];
|
||||
const secondCall = mockShopifyService.executeWithRetry.mock.calls[1][0];
|
||||
|
||||
// Execute the functions to check the variables
|
||||
await firstCall();
|
||||
await secondCall();
|
||||
|
||||
expect(mockShopifyService.executeQuery).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(String),
|
||||
{
|
||||
query: "tag:test-tag",
|
||||
first: 50,
|
||||
after: null,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockShopifyService.executeQuery).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(String),
|
||||
{
|
||||
query: "tag:test-tag",
|
||||
first: 50,
|
||||
after: "eyJsYXN0X2lkIjoxMjN9",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle empty product response", async () => {
|
||||
const mockResponse = {
|
||||
products: {
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
|
||||
|
||||
const products = await productService.fetchProductsByTag(
|
||||
"nonexistent-tag"
|
||||
);
|
||||
|
||||
expect(products).toHaveLength(0);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"Successfully fetched 0 products with tag: nonexistent-tag"
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle API errors during product fetching", async () => {
|
||||
const apiError = new Error("GraphQL API error: Invalid query");
|
||||
mockShopifyService.executeWithRetry.mockRejectedValue(apiError);
|
||||
|
||||
await expect(
|
||||
productService.fetchProductsByTag("test-tag")
|
||||
).rejects.toThrow(
|
||||
"Product fetching failed: GraphQL API error: Invalid query"
|
||||
);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
"Failed to fetch products with tag test-tag: GraphQL API error: Invalid query"
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle invalid response structure", async () => {
|
||||
const invalidResponse = {
|
||||
// Missing products field
|
||||
data: {
|
||||
shop: { name: "Test Shop" },
|
||||
},
|
||||
};
|
||||
|
||||
mockShopifyService.executeWithRetry.mockResolvedValue(invalidResponse);
|
||||
|
||||
await expect(
|
||||
productService.fetchProductsByTag("test-tag")
|
||||
).rejects.toThrow(
|
||||
"Product fetching failed: Invalid response structure: missing products field"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Product Validation", () => {
|
||||
test("should validate products with valid data", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Valid Product 1",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: 29.99,
|
||||
title: "Variant 1",
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/789",
|
||||
price: 39.99,
|
||||
title: "Variant 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/Product/456",
|
||||
title: "Valid Product 2",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/101112",
|
||||
price: 19.99,
|
||||
title: "Single Variant",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const validProducts = await productService.validateProducts(products);
|
||||
|
||||
expect(validProducts).toHaveLength(2);
|
||||
expect(validProducts[0].variants).toHaveLength(2);
|
||||
expect(validProducts[1].variants).toHaveLength(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"Validated 2 products for price updates"
|
||||
);
|
||||
});
|
||||
|
||||
test("should skip products without variants", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product Without Variants",
|
||||
variants: [],
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/Product/456",
|
||||
title: "Product With Variants",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/789",
|
||||
price: 29.99,
|
||||
title: "Valid Variant",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const validProducts = await productService.validateProducts(products);
|
||||
|
||||
expect(validProducts).toHaveLength(1);
|
||||
expect(validProducts[0].title).toBe("Product With Variants");
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith(
|
||||
'Skipping product "Product Without Variants" - no variants found'
|
||||
);
|
||||
});
|
||||
|
||||
test("should skip variants with invalid prices", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product With Mixed Variants",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: 29.99,
|
||||
title: "Valid Variant",
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/789",
|
||||
price: "invalid",
|
||||
title: "Invalid Price Variant",
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/101112",
|
||||
price: -10.0,
|
||||
title: "Negative Price Variant",
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/131415",
|
||||
price: NaN,
|
||||
title: "NaN Price Variant",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const validProducts = await productService.validateProducts(products);
|
||||
|
||||
expect(validProducts).toHaveLength(1);
|
||||
expect(validProducts[0].variants).toHaveLength(1);
|
||||
expect(validProducts[0].variants[0].title).toBe("Valid Variant");
|
||||
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith(
|
||||
'Skipping variant "Invalid Price Variant" in product "Product With Mixed Variants" - invalid price: invalid'
|
||||
);
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith(
|
||||
'Skipping variant "Negative Price Variant" in product "Product With Mixed Variants" - negative price: -10'
|
||||
);
|
||||
});
|
||||
|
||||
test("should skip products with no valid variants", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product With All Invalid Variants",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: "invalid",
|
||||
title: "Invalid Variant 1",
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/789",
|
||||
price: -5.0,
|
||||
title: "Invalid Variant 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const validProducts = await productService.validateProducts(products);
|
||||
|
||||
expect(validProducts).toHaveLength(0);
|
||||
expect(mockLogger.warning).toHaveBeenCalledWith(
|
||||
'Skipping product "Product With All Invalid Variants" - no variants with valid prices'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Product Summary Statistics", () => {
|
||||
test("should calculate correct summary for products", () => {
|
||||
const products = [
|
||||
{
|
||||
variants: [{ price: 10.0 }, { price: 20.0 }],
|
||||
},
|
||||
{
|
||||
variants: [{ price: 5.0 }, { price: 50.0 }, { price: 30.0 }],
|
||||
},
|
||||
];
|
||||
|
||||
const summary = productService.getProductSummary(products);
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalProducts: 2,
|
||||
totalVariants: 5,
|
||||
priceRange: {
|
||||
min: 5.0,
|
||||
max: 50.0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty product list", () => {
|
||||
const products = [];
|
||||
const summary = productService.getProductSummary(products);
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalProducts: 0,
|
||||
totalVariants: 0,
|
||||
priceRange: {
|
||||
min: 0,
|
||||
max: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle single product with single variant", () => {
|
||||
const products = [
|
||||
{
|
||||
variants: [{ price: 25.99 }],
|
||||
},
|
||||
];
|
||||
|
||||
const summary = productService.getProductSummary(products);
|
||||
|
||||
expect(summary).toEqual({
|
||||
totalProducts: 1,
|
||||
totalVariants: 1,
|
||||
priceRange: {
|
||||
min: 25.99,
|
||||
max: 25.99,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Single Variant Price Updates", () => {
|
||||
test("should update variant price successfully", async () => {
|
||||
const mockResponse = {
|
||||
productVariantsBulkUpdate: {
|
||||
productVariants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/123",
|
||||
price: "32.99",
|
||||
compareAtPrice: "29.99",
|
||||
},
|
||||
],
|
||||
userErrors: [],
|
||||
},
|
||||
};
|
||||
|
||||
mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
|
||||
|
||||
const variant = {
|
||||
id: "gid://shopify/ProductVariant/123",
|
||||
price: 29.99,
|
||||
};
|
||||
|
||||
const result = await productService.updateVariantPrice(
|
||||
variant,
|
||||
"gid://shopify/Product/123",
|
||||
32.99,
|
||||
29.99
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.updatedVariant.id).toBe("gid://shopify/ProductVariant/123");
|
||||
expect(result.updatedVariant.price).toBe("32.99");
|
||||
|
||||
expect(mockShopifyService.executeWithRetry).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
mockLogger
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle Shopify API user errors", async () => {
|
||||
const mockResponse = {
|
||||
productVariantsBulkUpdate: {
|
||||
productVariants: [],
|
||||
userErrors: [
|
||||
{
|
||||
field: "price",
|
||||
message: "Price must be greater than 0",
|
||||
},
|
||||
{
|
||||
field: "compareAtPrice",
|
||||
message: "Compare at price must be greater than price",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
|
||||
|
||||
const variant = {
|
||||
id: "gid://shopify/ProductVariant/123",
|
||||
price: 0,
|
||||
};
|
||||
|
||||
const result = await productService.updateVariantPrice(
|
||||
variant,
|
||||
"gid://shopify/Product/123",
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Shopify API errors:");
|
||||
expect(result.error).toContain("price: Price must be greater than 0");
|
||||
expect(result.error).toContain(
|
||||
"compareAtPrice: Compare at price must be greater than price"
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle network errors during variant update", async () => {
|
||||
const networkError = new Error("Network connection failed");
|
||||
mockShopifyService.executeWithRetry.mockRejectedValue(networkError);
|
||||
|
||||
const variant = {
|
||||
id: "gid://shopify/ProductVariant/123",
|
||||
price: 29.99,
|
||||
};
|
||||
|
||||
const result = await productService.updateVariantPrice(
|
||||
variant,
|
||||
"gid://shopify/Product/123",
|
||||
32.99,
|
||||
29.99
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Network connection failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Batch Product Price Updates", () => {
|
||||
test("should update multiple products successfully", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product 1",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: 20.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/Product/789",
|
||||
title: "Product 2",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/101112",
|
||||
price: 30.0,
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/131415",
|
||||
price: 40.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock successful responses for all variants
|
||||
mockShopifyService.executeWithRetry.mockResolvedValue({
|
||||
productVariantsBulkUpdate: {
|
||||
productVariants: [
|
||||
{ id: "test", price: "22.00", compareAtPrice: "20.00" },
|
||||
],
|
||||
userErrors: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Mock delay function
|
||||
jest.spyOn(productService, "delay").mockResolvedValue();
|
||||
|
||||
const results = await productService.updateProductPrices(products, 10);
|
||||
|
||||
expect(results.totalProducts).toBe(2);
|
||||
expect(results.totalVariants).toBe(3);
|
||||
expect(results.successfulUpdates).toBe(3);
|
||||
expect(results.failedUpdates).toBe(0);
|
||||
expect(results.errors).toHaveLength(0);
|
||||
|
||||
expect(mockLogger.logProductUpdate).toHaveBeenCalledTimes(3);
|
||||
expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("should handle mixed success and failure scenarios", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product 1",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: 20.0,
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/789",
|
||||
price: 30.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock first call succeeds, second fails
|
||||
mockShopifyService.executeWithRetry
|
||||
.mockResolvedValueOnce({
|
||||
productVariantsBulkUpdate: {
|
||||
productVariants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: "22.00",
|
||||
compareAtPrice: "20.00",
|
||||
},
|
||||
],
|
||||
userErrors: [],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
productVariantsBulkUpdate: {
|
||||
productVariants: [],
|
||||
userErrors: [
|
||||
{
|
||||
field: "price",
|
||||
message: "Invalid price format",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(productService, "delay").mockResolvedValue();
|
||||
|
||||
const results = await productService.updateProductPrices(products, 10);
|
||||
|
||||
expect(results.totalProducts).toBe(1);
|
||||
expect(results.totalVariants).toBe(2);
|
||||
expect(results.successfulUpdates).toBe(1);
|
||||
expect(results.failedUpdates).toBe(1);
|
||||
expect(results.errors).toHaveLength(1);
|
||||
|
||||
expect(results.errors[0]).toEqual({
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Product 1",
|
||||
variantId: "gid://shopify/ProductVariant/789",
|
||||
errorMessage: "Shopify API errors: price: Invalid price format",
|
||||
});
|
||||
|
||||
expect(mockLogger.logProductUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.logProductError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle price calculation errors", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product 1",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: "invalid", // This will cause calculateNewPrice to throw
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock calculateNewPrice to throw an error
|
||||
const { calculateNewPrice } = require("../../src/utils/price");
|
||||
jest.mock("../../src/utils/price");
|
||||
require("../../src/utils/price").calculateNewPrice = jest
|
||||
.fn()
|
||||
.mockImplementation(() => {
|
||||
throw new Error("Invalid price format");
|
||||
});
|
||||
|
||||
jest.spyOn(productService, "delay").mockResolvedValue();
|
||||
|
||||
const results = await productService.updateProductPrices(products, 10);
|
||||
|
||||
expect(results.totalProducts).toBe(1);
|
||||
expect(results.totalVariants).toBe(1);
|
||||
expect(results.successfulUpdates).toBe(0);
|
||||
expect(results.failedUpdates).toBe(1);
|
||||
expect(results.errors).toHaveLength(1);
|
||||
|
||||
expect(results.errors[0].errorMessage).toContain(
|
||||
"Price calculation failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("should process products in batches with delays", async () => {
|
||||
// Create products that exceed batch size
|
||||
const products = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `gid://shopify/Product/${i}`,
|
||||
title: `Product ${i}`,
|
||||
variants: [
|
||||
{
|
||||
id: `gid://shopify/ProductVariant/${i}`,
|
||||
price: 10.0 + i,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
mockShopifyService.executeWithRetry.mockResolvedValue({
|
||||
productVariantUpdate: {
|
||||
productVariant: { id: "test", price: "11.00" },
|
||||
userErrors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const delaySpy = jest.spyOn(productService, "delay").mockResolvedValue();
|
||||
|
||||
await productService.updateProductPrices(products, 10);
|
||||
|
||||
// Should have delays between batches (batch size is 10, so 3 batches total)
|
||||
// Delays should be called 2 times (between batch 1-2 and 2-3)
|
||||
expect(delaySpy).toHaveBeenCalledTimes(2);
|
||||
expect(delaySpy).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Scenarios", () => {
|
||||
test("should handle executeWithRetry failures gracefully", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product 1",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: 20.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const retryError = new Error("Max retries exceeded");
|
||||
retryError.errorHistory = [
|
||||
{ attempt: 1, error: "Rate limit", retryable: true },
|
||||
{ attempt: 2, error: "Rate limit", retryable: true },
|
||||
{ attempt: 3, error: "Rate limit", retryable: true },
|
||||
];
|
||||
|
||||
mockShopifyService.executeWithRetry.mockRejectedValue(retryError);
|
||||
jest.spyOn(productService, "delay").mockResolvedValue();
|
||||
|
||||
const results = await productService.updateProductPrices(products, 10);
|
||||
|
||||
expect(results.successfulUpdates).toBe(0);
|
||||
expect(results.failedUpdates).toBe(1);
|
||||
expect(results.errors[0].errorMessage).toContain("Max retries exceeded");
|
||||
});
|
||||
|
||||
test("should continue processing after individual failures", async () => {
|
||||
const products = [
|
||||
{
|
||||
id: "gid://shopify/Product/123",
|
||||
title: "Product 1",
|
||||
variants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/456",
|
||||
price: 20.0,
|
||||
},
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/789",
|
||||
price: 30.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// First call fails, second succeeds
|
||||
mockShopifyService.executeWithRetry
|
||||
.mockRejectedValueOnce(new Error("Network timeout"))
|
||||
.mockResolvedValueOnce({
|
||||
productVariantsBulkUpdate: {
|
||||
productVariants: [
|
||||
{
|
||||
id: "gid://shopify/ProductVariant/789",
|
||||
price: "33.00",
|
||||
compareAtPrice: "30.00",
|
||||
},
|
||||
],
|
||||
userErrors: [],
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(productService, "delay").mockResolvedValue();
|
||||
|
||||
const results = await productService.updateProductPrices(products, 10);
|
||||
|
||||
expect(results.successfulUpdates).toBe(1);
|
||||
expect(results.failedUpdates).toBe(1);
|
||||
expect(mockLogger.logProductUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.logProductError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
559
tests/services/progress.test.js
Normal file
559
tests/services/progress.test.js
Normal file
@@ -0,0 +1,559 @@
|
||||
const ProgressService = require("../../src/services/progress");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
|
||||
describe("ProgressService", () => {
|
||||
let progressService;
|
||||
let testFilePath;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use a unique test file for each test to avoid conflicts
|
||||
testFilePath = `test-progress-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}.md`;
|
||||
progressService = new ProgressService(testFilePath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test file after each test
|
||||
try {
|
||||
await fs.unlink(testFilePath);
|
||||
} catch (error) {
|
||||
// File might not exist, that's okay
|
||||
}
|
||||
});
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
test("should format timestamp correctly", () => {
|
||||
const testDate = new Date("2024-01-15T14:30:45.123Z");
|
||||
const formatted = progressService.formatTimestamp(testDate);
|
||||
|
||||
expect(formatted).toBe("2024-01-15 14:30:45 UTC");
|
||||
});
|
||||
|
||||
test("should use current date when no date provided", () => {
|
||||
const formatted = progressService.formatTimestamp();
|
||||
|
||||
// Should be a valid timestamp format
|
||||
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||
});
|
||||
|
||||
test("should handle different dates correctly", () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: new Date("2023-12-31T23:59:59.999Z"),
|
||||
expected: "2023-12-31 23:59:59 UTC",
|
||||
},
|
||||
{
|
||||
input: new Date("2024-01-01T00:00:00.000Z"),
|
||||
expected: "2024-01-01 00:00:00 UTC",
|
||||
},
|
||||
{
|
||||
input: new Date("2024-06-15T12:00:00.500Z"),
|
||||
expected: "2024-06-15 12:00:00 UTC",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
expect(progressService.formatTimestamp(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("logOperationStart", () => {
|
||||
test("should create progress file and log operation start", async () => {
|
||||
const config = {
|
||||
targetTag: "test-tag",
|
||||
priceAdjustmentPercentage: 10,
|
||||
};
|
||||
|
||||
await progressService.logOperationStart(config);
|
||||
|
||||
// Check that file was created
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("# Shopify Price Update Progress Log");
|
||||
expect(content).toContain("## Price Update Operation -");
|
||||
expect(content).toContain("Target Tag: test-tag");
|
||||
expect(content).toContain("Price Adjustment: 10%");
|
||||
expect(content).toContain("**Configuration:**");
|
||||
expect(content).toContain("**Progress:**");
|
||||
});
|
||||
|
||||
test("should handle negative percentage", async () => {
|
||||
const config = {
|
||||
targetTag: "clearance",
|
||||
priceAdjustmentPercentage: -25,
|
||||
};
|
||||
|
||||
await progressService.logOperationStart(config);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Target Tag: clearance");
|
||||
expect(content).toContain("Price Adjustment: -25%");
|
||||
});
|
||||
|
||||
test("should handle special characters in tag", async () => {
|
||||
const config = {
|
||||
targetTag: "sale-2024_special!",
|
||||
priceAdjustmentPercentage: 15.5,
|
||||
};
|
||||
|
||||
await progressService.logOperationStart(config);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Target Tag: sale-2024_special!");
|
||||
expect(content).toContain("Price Adjustment: 15.5%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logProductUpdate", () => {
|
||||
test("should log successful product update", async () => {
|
||||
// First create the file
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123456789",
|
||||
productTitle: "Test Product",
|
||||
variantId: "gid://shopify/ProductVariant/987654321",
|
||||
oldPrice: 29.99,
|
||||
newPrice: 32.99,
|
||||
};
|
||||
|
||||
await progressService.logProductUpdate(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"✅ **Test Product** (gid://shopify/Product/123456789)"
|
||||
);
|
||||
expect(content).toContain(
|
||||
"Variant: gid://shopify/ProductVariant/987654321"
|
||||
);
|
||||
expect(content).toContain("Price: $29.99 → $32.99");
|
||||
expect(content).toContain("Updated:");
|
||||
});
|
||||
|
||||
test("should handle products with special characters in title", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: 'Product with "Quotes" & Special Chars!',
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
oldPrice: 10.0,
|
||||
newPrice: 11.0,
|
||||
};
|
||||
|
||||
await progressService.logProductUpdate(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain('**Product with "Quotes" & Special Chars!**');
|
||||
});
|
||||
|
||||
test("should handle decimal prices correctly", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 5.5,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Decimal Price Product",
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
oldPrice: 19.95,
|
||||
newPrice: 21.05,
|
||||
};
|
||||
|
||||
await progressService.logProductUpdate(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Price: $19.95 → $21.05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logError", () => {
|
||||
test("should log error with all details", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Failed Product",
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
errorMessage: "Invalid price data",
|
||||
};
|
||||
|
||||
await progressService.logError(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"❌ **Failed Product** (gid://shopify/Product/123)"
|
||||
);
|
||||
expect(content).toContain("Variant: gid://shopify/ProductVariant/456");
|
||||
expect(content).toContain("Error: Invalid price data");
|
||||
expect(content).toContain("Failed:");
|
||||
});
|
||||
|
||||
test("should handle error without variant ID", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Failed Product",
|
||||
errorMessage: "Product not found",
|
||||
};
|
||||
|
||||
await progressService.logError(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"❌ **Failed Product** (gid://shopify/Product/123)"
|
||||
);
|
||||
expect(content).not.toContain("Variant:");
|
||||
expect(content).toContain("Error: Product not found");
|
||||
});
|
||||
|
||||
test("should handle complex error messages", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Complex Error Product",
|
||||
variantId: "gid://shopify/ProductVariant/456",
|
||||
errorMessage:
|
||||
"GraphQL error: Field 'price' of type 'Money!' must not be null",
|
||||
};
|
||||
|
||||
await progressService.logError(entry);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain(
|
||||
"Error: GraphQL error: Field 'price' of type 'Money!' must not be null"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logCompletionSummary", () => {
|
||||
test("should log completion summary with all statistics", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const startTime = new Date(Date.now() - 5000); // 5 seconds ago
|
||||
const summary = {
|
||||
totalProducts: 10,
|
||||
successfulUpdates: 8,
|
||||
failedUpdates: 2,
|
||||
startTime: startTime,
|
||||
};
|
||||
|
||||
await progressService.logCompletionSummary(summary);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("**Summary:**");
|
||||
expect(content).toContain("Total Products Processed: 10");
|
||||
expect(content).toContain("Successful Updates: 8");
|
||||
expect(content).toContain("Failed Updates: 2");
|
||||
expect(content).toContain("Duration: 5 seconds");
|
||||
expect(content).toContain("Completed:");
|
||||
expect(content).toContain("---");
|
||||
});
|
||||
|
||||
test("should handle summary without start time", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalProducts: 5,
|
||||
successfulUpdates: 5,
|
||||
failedUpdates: 0,
|
||||
};
|
||||
|
||||
await progressService.logCompletionSummary(summary);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Duration: Unknown seconds");
|
||||
});
|
||||
|
||||
test("should handle zero statistics", async () => {
|
||||
await progressService.logOperationStart({
|
||||
targetTag: "test",
|
||||
priceAdjustmentPercentage: 10,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalProducts: 0,
|
||||
successfulUpdates: 0,
|
||||
failedUpdates: 0,
|
||||
startTime: new Date(),
|
||||
};
|
||||
|
||||
await progressService.logCompletionSummary(summary);
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("Total Products Processed: 0");
|
||||
expect(content).toContain("Successful Updates: 0");
|
||||
expect(content).toContain("Failed Updates: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("categorizeError", () => {
|
||||
test("should categorize rate limiting errors", () => {
|
||||
const testCases = [
|
||||
"Rate limit exceeded",
|
||||
"HTTP 429 Too Many Requests",
|
||||
"Request was throttled",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Rate Limiting"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize network errors", () => {
|
||||
const testCases = [
|
||||
"Network connection failed",
|
||||
"Connection timeout",
|
||||
"Network error occurred",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Network Issues"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize authentication errors", () => {
|
||||
const testCases = [
|
||||
"Authentication failed",
|
||||
"HTTP 401 Unauthorized",
|
||||
"Invalid authentication credentials",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Authentication"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize permission errors", () => {
|
||||
const testCases = [
|
||||
"Permission denied",
|
||||
"HTTP 403 Forbidden",
|
||||
"Insufficient permissions",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Permissions"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize not found errors", () => {
|
||||
const testCases = [
|
||||
"Product not found",
|
||||
"HTTP 404 Not Found",
|
||||
"Resource not found",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Resource Not Found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize validation errors", () => {
|
||||
const testCases = [
|
||||
"Validation error: Invalid price",
|
||||
"Invalid product data",
|
||||
"Price validation failed",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Data Validation"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize server errors", () => {
|
||||
const testCases = [
|
||||
"Internal server error",
|
||||
"HTTP 500 Server Error",
|
||||
"HTTP 502 Bad Gateway",
|
||||
"HTTP 503 Service Unavailable",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Server Errors"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize Shopify API errors", () => {
|
||||
const testCases = [
|
||||
"Shopify API error occurred",
|
||||
"Shopify API request failed",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe(
|
||||
"Shopify API"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should categorize unknown errors as Other", () => {
|
||||
const testCases = [
|
||||
"Something went wrong",
|
||||
"Unexpected error",
|
||||
"Random failure message",
|
||||
];
|
||||
|
||||
testCases.forEach((errorMessage) => {
|
||||
expect(progressService.categorizeError(errorMessage)).toBe("Other");
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle case insensitive categorization", () => {
|
||||
expect(progressService.categorizeError("RATE LIMIT EXCEEDED")).toBe(
|
||||
"Rate Limiting"
|
||||
);
|
||||
expect(progressService.categorizeError("Network Connection Failed")).toBe(
|
||||
"Network Issues"
|
||||
);
|
||||
expect(progressService.categorizeError("AUTHENTICATION FAILED")).toBe(
|
||||
"Authentication"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProgressEntry", () => {
|
||||
test("should create progress entry with timestamp", () => {
|
||||
const data = {
|
||||
productId: "gid://shopify/Product/123",
|
||||
productTitle: "Test Product",
|
||||
status: "success",
|
||||
};
|
||||
|
||||
const entry = progressService.createProgressEntry(data);
|
||||
|
||||
expect(entry).toHaveProperty("timestamp");
|
||||
expect(entry.timestamp).toBeInstanceOf(Date);
|
||||
expect(entry.productId).toBe("gid://shopify/Product/123");
|
||||
expect(entry.productTitle).toBe("Test Product");
|
||||
expect(entry.status).toBe("success");
|
||||
});
|
||||
|
||||
test("should preserve all original data", () => {
|
||||
const data = {
|
||||
productId: "gid://shopify/Product/456",
|
||||
productTitle: "Another Product",
|
||||
variantId: "gid://shopify/ProductVariant/789",
|
||||
oldPrice: 10.0,
|
||||
newPrice: 11.0,
|
||||
errorMessage: "Some error",
|
||||
};
|
||||
|
||||
const entry = progressService.createProgressEntry(data);
|
||||
|
||||
expect(entry.productId).toBe(data.productId);
|
||||
expect(entry.productTitle).toBe(data.productTitle);
|
||||
expect(entry.variantId).toBe(data.variantId);
|
||||
expect(entry.oldPrice).toBe(data.oldPrice);
|
||||
expect(entry.newPrice).toBe(data.newPrice);
|
||||
expect(entry.errorMessage).toBe(data.errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendToProgressFile", () => {
|
||||
test("should create file with header when file doesn't exist", async () => {
|
||||
await progressService.appendToProgressFile("Test content");
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
expect(content).toContain("# Shopify Price Update Progress Log");
|
||||
expect(content).toContain(
|
||||
"This file tracks the progress of price update operations."
|
||||
);
|
||||
expect(content).toContain("Test content");
|
||||
});
|
||||
|
||||
test("should append to existing file without adding header", async () => {
|
||||
// Create file first
|
||||
await progressService.appendToProgressFile("First content");
|
||||
|
||||
// Append more content
|
||||
await progressService.appendToProgressFile("Second content");
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf8");
|
||||
|
||||
// Should only have one header
|
||||
const headerCount = (
|
||||
content.match(/# Shopify Price Update Progress Log/g) || []
|
||||
).length;
|
||||
expect(headerCount).toBe(1);
|
||||
|
||||
expect(content).toContain("First content");
|
||||
expect(content).toContain("Second content");
|
||||
});
|
||||
|
||||
test("should handle file write errors gracefully", async () => {
|
||||
// Mock fs.appendFile to throw an error
|
||||
const originalAppendFile = fs.appendFile;
|
||||
const mockAppendFile = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Permission denied"));
|
||||
fs.appendFile = mockAppendFile;
|
||||
|
||||
// Should not throw an error, but should log warnings
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await progressService.appendToProgressFile("Test content");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Warning: Failed to write to progress file")
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
fs.appendFile = originalAppendFile;
|
||||
});
|
||||
});
|
||||
});
|
||||
538
tests/services/shopify.test.js
Normal file
538
tests/services/shopify.test.js
Normal file
@@ -0,0 +1,538 @@
|
||||
const ShopifyService = require("../../src/services/shopify");
|
||||
const { getConfig } = require("../../src/config/environment");
|
||||
|
||||
// Mock the environment config
|
||||
jest.mock("../../src/config/environment");
|
||||
|
||||
describe("ShopifyService Integration Tests", () => {
|
||||
let shopifyService;
|
||||
let mockConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock configuration
|
||||
mockConfig = {
|
||||
shopDomain: "test-shop.myshopify.com",
|
||||
accessToken: "test-access-token",
|
||||
targetTag: "test-tag",
|
||||
priceAdjustmentPercentage: 10,
|
||||
};
|
||||
|
||||
getConfig.mockReturnValue(mockConfig);
|
||||
shopifyService = new ShopifyService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GraphQL Query Execution", () => {
|
||||
test("should execute product query with mock response", async () => {
|
||||
const query = `
|
||||
query getProductsByTag($tag: String!, $first: Int!, $after: String) {
|
||||
products(first: $first, after: $after, query: $tag) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
tags
|
||||
variants(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
price
|
||||
compareAtPrice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {
|
||||
tag: "tag:test-tag",
|
||||
first: 50,
|
||||
after: null,
|
||||
};
|
||||
|
||||
const response = await shopifyService.executeQuery(query, variables);
|
||||
|
||||
expect(response).toHaveProperty("products");
|
||||
expect(response.products).toHaveProperty("edges");
|
||||
expect(response.products).toHaveProperty("pageInfo");
|
||||
expect(response.products.pageInfo).toHaveProperty("hasNextPage", false);
|
||||
expect(response.products.pageInfo).toHaveProperty("endCursor", null);
|
||||
});
|
||||
|
||||
test("should handle query with pagination variables", async () => {
|
||||
const query = `query getProductsByTag($tag: String!, $first: Int!, $after: String) {
|
||||
products(first: $first, after: $after, query: $tag) {
|
||||
edges { node { id title } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = {
|
||||
tag: "tag:sale",
|
||||
first: 25,
|
||||
after: "eyJsYXN0X2lkIjoxMjM0NTY3ODkwfQ==",
|
||||
};
|
||||
|
||||
const response = await shopifyService.executeQuery(query, variables);
|
||||
|
||||
expect(response).toHaveProperty("products");
|
||||
expect(Array.isArray(response.products.edges)).toBe(true);
|
||||
});
|
||||
|
||||
test("should throw error for unsupported query types", async () => {
|
||||
const unsupportedQuery = `
|
||||
query getShopInfo {
|
||||
shop {
|
||||
name
|
||||
domain
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await expect(
|
||||
shopifyService.executeQuery(unsupportedQuery)
|
||||
).rejects.toThrow("Simulated API - Query not implemented");
|
||||
});
|
||||
|
||||
test("should handle empty query variables", async () => {
|
||||
const query = `query getProductsByTag($tag: String!, $first: Int!, $after: String) {
|
||||
products(first: $first, after: $after, query: $tag) {
|
||||
edges { node { id } }
|
||||
}
|
||||
}`;
|
||||
|
||||
// Should not throw when variables is undefined
|
||||
const response = await shopifyService.executeQuery(query);
|
||||
expect(response).toHaveProperty("products");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GraphQL Mutation Execution", () => {
|
||||
test("should execute product variant update mutation successfully", async () => {
|
||||
const mutation = `
|
||||
mutation productVariantUpdate($input: ProductVariantInput!) {
|
||||
productVariantUpdate(input: $input) {
|
||||
productVariant {
|
||||
id
|
||||
price
|
||||
compareAtPrice
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
id: "gid://shopify/ProductVariant/123456789",
|
||||
price: "29.99",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await shopifyService.executeMutation(
|
||||
mutation,
|
||||
variables
|
||||
);
|
||||
|
||||
expect(response).toHaveProperty("productVariantUpdate");
|
||||
expect(response.productVariantUpdate).toHaveProperty("productVariant");
|
||||
expect(response.productVariantUpdate).toHaveProperty("userErrors", []);
|
||||
expect(response.productVariantUpdate.productVariant.id).toBe(
|
||||
variables.input.id
|
||||
);
|
||||
expect(response.productVariantUpdate.productVariant.price).toBe(
|
||||
variables.input.price
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle mutation with compare at price", async () => {
|
||||
const mutation = `mutation productVariantUpdate($input: ProductVariantInput!) {
|
||||
productVariantUpdate(input: $input) {
|
||||
productVariant { id price compareAtPrice }
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
id: "gid://shopify/ProductVariant/987654321",
|
||||
price: "39.99",
|
||||
compareAtPrice: "49.99",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await shopifyService.executeMutation(
|
||||
mutation,
|
||||
variables
|
||||
);
|
||||
|
||||
expect(response.productVariantUpdate.productVariant.id).toBe(
|
||||
variables.input.id
|
||||
);
|
||||
expect(response.productVariantUpdate.productVariant.price).toBe(
|
||||
variables.input.price
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for unsupported mutation types", async () => {
|
||||
const unsupportedMutation = `
|
||||
mutation createProduct($input: ProductInput!) {
|
||||
productCreate(input: $input) {
|
||||
product { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
title: "New Product",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
shopifyService.executeMutation(unsupportedMutation, variables)
|
||||
).rejects.toThrow("Simulated API - Mutation not implemented");
|
||||
});
|
||||
|
||||
test("should handle mutation with empty variables", async () => {
|
||||
const mutation = `mutation productVariantUpdate($input: ProductVariantInput!) {
|
||||
productVariantUpdate(input: $input) {
|
||||
productVariant { id price }
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
// Should handle when variables is undefined (will cause error accessing variables.input)
|
||||
await expect(shopifyService.executeMutation(mutation)).rejects.toThrow(
|
||||
"Cannot read properties of undefined"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limiting and Retry Logic", () => {
|
||||
test("should identify rate limiting errors correctly", () => {
|
||||
const rateLimitErrors = [
|
||||
new Error("HTTP 429 Too Many Requests"),
|
||||
new Error("Rate limit exceeded"),
|
||||
new Error("Request was throttled"),
|
||||
new Error("API rate limit reached"),
|
||||
];
|
||||
|
||||
rateLimitErrors.forEach((error) => {
|
||||
expect(shopifyService.isRateLimitError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should identify network errors correctly", () => {
|
||||
const networkErrors = [
|
||||
{ code: "ECONNRESET", message: "Connection reset" },
|
||||
{ code: "ENOTFOUND", message: "Host not found" },
|
||||
{ code: "ECONNREFUSED", message: "Connection refused" },
|
||||
{ code: "ETIMEDOUT", message: "Connection timeout" },
|
||||
{ code: "EAI_AGAIN", message: "DNS lookup failed" },
|
||||
new Error("Network connection failed"),
|
||||
new Error("Connection timeout occurred"),
|
||||
];
|
||||
|
||||
networkErrors.forEach((error) => {
|
||||
expect(shopifyService.isNetworkError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should identify server errors correctly", () => {
|
||||
const serverErrors = [
|
||||
new Error("HTTP 500 Internal Server Error"),
|
||||
new Error("HTTP 502 Bad Gateway"),
|
||||
new Error("HTTP 503 Service Unavailable"),
|
||||
new Error("HTTP 504 Gateway Timeout"),
|
||||
new Error("HTTP 505 HTTP Version Not Supported"),
|
||||
];
|
||||
|
||||
serverErrors.forEach((error) => {
|
||||
expect(shopifyService.isServerError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should identify Shopify temporary errors correctly", () => {
|
||||
const shopifyErrors = [
|
||||
new Error("Internal server error"),
|
||||
new Error("Service unavailable"),
|
||||
new Error("Request timeout"),
|
||||
new Error("Temporarily unavailable"),
|
||||
new Error("Under maintenance"),
|
||||
];
|
||||
|
||||
shopifyErrors.forEach((error) => {
|
||||
expect(shopifyService.isShopifyTemporaryError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should calculate retry delays with exponential backoff", () => {
|
||||
const baseDelay = 1000;
|
||||
shopifyService.baseRetryDelay = baseDelay;
|
||||
|
||||
// Test standard exponential backoff
|
||||
expect(
|
||||
shopifyService.calculateRetryDelay(1, new Error("Network error"))
|
||||
).toBe(baseDelay);
|
||||
expect(
|
||||
shopifyService.calculateRetryDelay(2, new Error("Network error"))
|
||||
).toBe(baseDelay * 2);
|
||||
expect(
|
||||
shopifyService.calculateRetryDelay(3, new Error("Network error"))
|
||||
).toBe(baseDelay * 4);
|
||||
|
||||
// Test rate limit delays (should be doubled)
|
||||
expect(
|
||||
shopifyService.calculateRetryDelay(1, new Error("Rate limit exceeded"))
|
||||
).toBe(baseDelay * 2);
|
||||
expect(shopifyService.calculateRetryDelay(2, new Error("HTTP 429"))).toBe(
|
||||
baseDelay * 4
|
||||
);
|
||||
expect(
|
||||
shopifyService.calculateRetryDelay(3, new Error("throttled"))
|
||||
).toBe(baseDelay * 8);
|
||||
});
|
||||
|
||||
test("should execute operation with retry logic for retryable errors", async () => {
|
||||
let attemptCount = 0;
|
||||
const mockOperation = jest.fn().mockImplementation(() => {
|
||||
attemptCount++;
|
||||
if (attemptCount < 3) {
|
||||
throw new Error("HTTP 429 Rate limit exceeded");
|
||||
}
|
||||
return { success: true, attempt: attemptCount };
|
||||
});
|
||||
|
||||
// Mock sleep to avoid actual delays in tests
|
||||
jest.spyOn(shopifyService, "sleep").mockResolvedValue();
|
||||
|
||||
const result = await shopifyService.executeWithRetry(mockOperation);
|
||||
|
||||
expect(result).toEqual({ success: true, attempt: 3 });
|
||||
expect(mockOperation).toHaveBeenCalledTimes(3);
|
||||
expect(shopifyService.sleep).toHaveBeenCalledTimes(2); // 2 retries
|
||||
});
|
||||
|
||||
test("should fail immediately for non-retryable errors", async () => {
|
||||
const mockOperation = jest.fn().mockImplementation(() => {
|
||||
throw new Error("HTTP 400 Bad Request");
|
||||
});
|
||||
|
||||
await expect(
|
||||
shopifyService.executeWithRetry(mockOperation)
|
||||
).rejects.toThrow("Non-retryable error: HTTP 400 Bad Request");
|
||||
|
||||
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should fail after max retries for retryable errors", async () => {
|
||||
const mockOperation = jest.fn().mockImplementation(() => {
|
||||
throw new Error("HTTP 503 Service Unavailable");
|
||||
});
|
||||
|
||||
// Mock sleep to avoid actual delays
|
||||
jest.spyOn(shopifyService, "sleep").mockResolvedValue();
|
||||
|
||||
await expect(
|
||||
shopifyService.executeWithRetry(mockOperation)
|
||||
).rejects.toThrow("Operation failed after 3 attempts");
|
||||
|
||||
expect(mockOperation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("should include error history in failed operations", async () => {
|
||||
const mockOperation = jest.fn().mockImplementation(() => {
|
||||
throw new Error("HTTP 500 Internal Server Error");
|
||||
});
|
||||
|
||||
jest.spyOn(shopifyService, "sleep").mockResolvedValue();
|
||||
|
||||
try {
|
||||
await shopifyService.executeWithRetry(mockOperation);
|
||||
} catch (error) {
|
||||
expect(error).toHaveProperty("errorHistory");
|
||||
expect(error.errorHistory).toHaveLength(3);
|
||||
expect(error).toHaveProperty("totalAttempts", 3);
|
||||
expect(error).toHaveProperty("lastError");
|
||||
|
||||
// Check error history structure
|
||||
error.errorHistory.forEach((historyEntry, index) => {
|
||||
expect(historyEntry).toHaveProperty("attempt", index + 1);
|
||||
expect(historyEntry).toHaveProperty(
|
||||
"error",
|
||||
"HTTP 500 Internal Server Error"
|
||||
);
|
||||
expect(historyEntry).toHaveProperty("timestamp");
|
||||
expect(historyEntry).toHaveProperty("retryable", true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should use logger for retry attempts when provided", async () => {
|
||||
const mockLogger = {
|
||||
logRetryAttempt: jest.fn(),
|
||||
error: jest.fn(),
|
||||
logRateLimit: jest.fn(),
|
||||
};
|
||||
|
||||
let attemptCount = 0;
|
||||
const mockOperation = jest.fn().mockImplementation(() => {
|
||||
attemptCount++;
|
||||
if (attemptCount < 2) {
|
||||
throw new Error("HTTP 429 Rate limit exceeded");
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
jest.spyOn(shopifyService, "sleep").mockResolvedValue();
|
||||
|
||||
await shopifyService.executeWithRetry(mockOperation, mockLogger);
|
||||
|
||||
expect(mockLogger.logRetryAttempt).toHaveBeenCalledWith(
|
||||
1,
|
||||
3,
|
||||
"HTTP 429 Rate limit exceeded"
|
||||
);
|
||||
expect(mockLogger.logRateLimit).toHaveBeenCalledWith(2); // 2 seconds delay
|
||||
});
|
||||
|
||||
test("should handle non-retryable errors with logger", async () => {
|
||||
const mockLogger = {
|
||||
logRetryAttempt: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
const mockOperation = jest.fn().mockImplementation(() => {
|
||||
throw new Error("HTTP 400 Bad Request");
|
||||
});
|
||||
|
||||
try {
|
||||
await shopifyService.executeWithRetry(mockOperation, mockLogger);
|
||||
} catch (error) {
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
"Non-retryable error encountered: HTTP 400 Bad Request"
|
||||
);
|
||||
expect(error).toHaveProperty("errorHistory");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Connection Testing", () => {
|
||||
test("should test connection successfully", async () => {
|
||||
const result = await shopifyService.testConnection();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle connection test failures gracefully", async () => {
|
||||
// Mock console.error to avoid test output noise
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Override the testConnection method to simulate failure
|
||||
shopifyService.testConnection = jest.fn().mockImplementation(async () => {
|
||||
console.error("Failed to connect to Shopify API: Connection refused");
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await shopifyService.testConnection();
|
||||
expect(result).toBe(false);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Call Limit Information", () => {
|
||||
test("should handle API call limit info when not available", async () => {
|
||||
const result = await shopifyService.getApiCallLimit();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle API call limit errors gracefully", async () => {
|
||||
// Mock console.warn to avoid test output noise
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Override method to simulate error
|
||||
shopifyService.getApiCallLimit = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => {
|
||||
console.warn(
|
||||
"Could not retrieve API call limit info: API not initialized"
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await shopifyService.getApiCallLimit();
|
||||
expect(result).toBeNull();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Classification", () => {
|
||||
test("should correctly classify retryable vs non-retryable errors", () => {
|
||||
const retryableErrors = [
|
||||
new Error("HTTP 429 Too Many Requests"),
|
||||
new Error("HTTP 500 Internal Server Error"),
|
||||
new Error("HTTP 502 Bad Gateway"),
|
||||
new Error("HTTP 503 Service Unavailable"),
|
||||
{ code: "ECONNRESET", message: "Connection reset" },
|
||||
{ code: "ETIMEDOUT", message: "Timeout" },
|
||||
new Error("Service temporarily unavailable"),
|
||||
];
|
||||
|
||||
const nonRetryableErrors = [
|
||||
new Error("HTTP 400 Bad Request"),
|
||||
new Error("HTTP 401 Unauthorized"),
|
||||
new Error("HTTP 403 Forbidden"),
|
||||
new Error("HTTP 404 Not Found"),
|
||||
new Error("Invalid input data"),
|
||||
new Error("Validation failed"),
|
||||
];
|
||||
|
||||
retryableErrors.forEach((error) => {
|
||||
expect(shopifyService.isRetryableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
nonRetryableErrors.forEach((error) => {
|
||||
expect(shopifyService.isRetryableError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sleep Utility", () => {
|
||||
test("should sleep for specified duration", async () => {
|
||||
const startTime = Date.now();
|
||||
await shopifyService.sleep(100); // 100ms
|
||||
const endTime = Date.now();
|
||||
|
||||
// Allow for some variance in timing
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(90);
|
||||
expect(endTime - startTime).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("should handle zero sleep duration", async () => {
|
||||
const startTime = Date.now();
|
||||
await shopifyService.sleep(0);
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user