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