Files
PriceUpdaterAppv2/tests/services/product.test.js
Spencer Grimes 1e6881ba86 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
2025-08-05 10:05:05 -05:00

854 lines
22 KiB
JavaScript

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);
});
});
});