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