/** * End-to-End Integration Tests for Rollback Workflow * These tests verify the complete rollback functionality works together */ const ShopifyPriceUpdater = require("../../src/index"); const { getConfig } = require("../../src/config/environment"); const ProductService = require("../../src/services/product"); const Logger = require("../../src/utils/logger"); const ProgressService = require("../../src/services/progress"); // Mock external dependencies but test internal integration jest.mock("../../src/config/environment"); jest.mock("../../src/services/shopify"); jest.mock("../../src/services/progress"); describe("Rollback Workflow Integration Tests", () => { let mockConfig; let mockShopifyService; let mockProgressService; beforeEach(() => { // Mock configuration for rollback mode mockConfig = { shopDomain: "test-shop.myshopify.com", accessToken: "test-token", targetTag: "rollback-test", priceAdjustmentPercentage: 10, // Not used in rollback but required operationMode: "rollback", }; // Mock Shopify service responses mockShopifyService = { testConnection: jest.fn().mockResolvedValue(true), executeQuery: jest.fn(), executeMutation: jest.fn(), executeWithRetry: jest.fn(), }; // Mock progress service mockProgressService = { logRollbackStart: jest.fn(), logRollbackUpdate: jest.fn(), logRollbackSummary: jest.fn(), logError: jest.fn(), logErrorAnalysis: jest.fn(), }; getConfig.mockReturnValue(mockConfig); ProgressService.mockImplementation(() => mockProgressService); // Mock ShopifyService constructor const ShopifyService = require("../../src/services/shopify"); ShopifyService.mockImplementation(() => mockShopifyService); }); afterEach(() => { jest.clearAllMocks(); }); describe("Complete Rollback Workflow", () => { test("should execute complete rollback workflow with successful operations", async () => { // Mock product fetching response const mockProductsResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/123", title: "Test Product 1", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/456", price: "50.00", compareAtPrice: "75.00", title: "Variant 1", }, }, ], }, }, }, { node: { id: "gid://shopify/Product/789", title: "Test Product 2", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/101112", price: "30.00", compareAtPrice: "40.00", title: "Variant 2", }, }, { node: { id: "gid://shopify/ProductVariant/131415", price: "20.00", compareAtPrice: "25.00", title: "Variant 3", }, }, ], }, }, }, ], pageInfo: { hasNextPage: false, endCursor: null, }, }, }; // Mock successful rollback mutation responses const mockRollbackResponse = { productVariantsBulkUpdate: { productVariants: [ { id: "test-variant", price: "75.00", compareAtPrice: null, }, ], userErrors: [], }, }; mockShopifyService.executeWithRetry .mockResolvedValueOnce(mockProductsResponse) // Product fetching .mockResolvedValue(mockRollbackResponse); // All rollback operations const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); // Verify successful completion expect(exitCode).toBe(0); // Verify rollback start was logged expect(mockProgressService.logRollbackStart).toHaveBeenCalledWith( mockConfig ); // Verify rollback operations were logged (3 variants) expect(mockProgressService.logRollbackUpdate).toHaveBeenCalledTimes(3); // Verify rollback summary was logged expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith( expect.objectContaining({ totalProducts: 2, totalVariants: 3, eligibleVariants: 3, successfulRollbacks: 3, failedRollbacks: 0, skippedVariants: 0, }) ); // Verify Shopify API calls expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(4); // 1 fetch + 3 rollbacks }); test("should handle mixed success and failure scenarios", async () => { // Mock product fetching response with mixed variants const mockProductsResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/123", title: "Mixed Product", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/456", price: "50.00", compareAtPrice: "75.00", title: "Valid Variant", }, }, { node: { id: "gid://shopify/ProductVariant/789", price: "30.00", compareAtPrice: null, // Will be skipped title: "No Compare-At Price", }, }, { node: { id: "gid://shopify/ProductVariant/101112", price: "20.00", compareAtPrice: "25.00", title: "Will Fail", }, }, ], }, }, }, ], pageInfo: { hasNextPage: false, endCursor: null, }, }, }; mockShopifyService.executeWithRetry .mockResolvedValueOnce(mockProductsResponse) // Product fetching .mockResolvedValueOnce({ // First rollback succeeds productVariantsBulkUpdate: { productVariants: [ { id: "gid://shopify/ProductVariant/456", price: "75.00", compareAtPrice: null, }, ], userErrors: [], }, }) .mockResolvedValueOnce({ // Second rollback fails productVariantsBulkUpdate: { productVariants: [], userErrors: [ { field: "price", message: "Invalid price format", }, ], }, }); const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); // Should still complete but with partial success (50% success rate = moderate) expect(exitCode).toBe(1); // Moderate success rate // Verify rollback summary reflects mixed results expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith( expect.objectContaining({ totalProducts: 1, totalVariants: 2, // Only eligible variants are processed eligibleVariants: 2, // Only 2 variants were eligible successfulRollbacks: 1, failedRollbacks: 1, skippedVariants: 0, // Skipped variants are filtered out during validation }) ); // Verify error logging expect(mockProgressService.logError).toHaveBeenCalledWith( expect.objectContaining({ productId: "gid://shopify/Product/123", productTitle: "Mixed Product", variantId: "gid://shopify/ProductVariant/101112", errorMessage: "Shopify API errors: price: Invalid price format", }) ); }); test("should handle products with no eligible variants", async () => { // Mock product fetching response with no eligible variants const mockProductsResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/123", title: "No Eligible Variants", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/456", price: "50.00", compareAtPrice: null, // No compare-at price title: "Variant 1", }, }, { node: { id: "gid://shopify/ProductVariant/789", price: "30.00", compareAtPrice: "30.00", // Same as current price title: "Variant 2", }, }, ], }, }, }, ], pageInfo: { hasNextPage: false, endCursor: null, }, }, }; mockShopifyService.executeWithRetry.mockResolvedValueOnce( mockProductsResponse ); const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); // Should complete successfully with no operations expect(exitCode).toBe(0); // Verify no rollback operations were attempted expect(mockProgressService.logRollbackUpdate).not.toHaveBeenCalled(); // Verify summary reflects no eligible variants expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith( expect.objectContaining({ totalProducts: 0, // No products with eligible variants totalVariants: 0, eligibleVariants: 0, successfulRollbacks: 0, failedRollbacks: 0, skippedVariants: 0, }) ); }); test("should handle API connection failures", async () => { mockShopifyService.testConnection.mockResolvedValue(false); const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); expect(exitCode).toBe(1); // Should log critical failure expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith( expect.objectContaining({ totalProducts: 0, errors: expect.arrayContaining([ expect.objectContaining({ errorMessage: "API connection failed", }), ]), }) ); }); test("should handle product fetching failures", async () => { mockShopifyService.executeWithRetry.mockRejectedValue( new Error("GraphQL API error") ); const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); expect(exitCode).toBe(1); // Should log critical failure expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith( expect.objectContaining({ totalProducts: 0, errors: expect.arrayContaining([ expect.objectContaining({ errorMessage: "Product fetching for rollback failed", }), ]), }) ); }); test("should handle rate limiting with retry logic", async () => { // Mock product fetching response const mockProductsResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/123", title: "Test Product", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/456", price: "50.00", compareAtPrice: "75.00", title: "Variant 1", }, }, ], }, }, }, ], pageInfo: { hasNextPage: false, endCursor: null, }, }, }; // Mock rate limit error followed by success const rateLimitError = new Error("Rate limit exceeded"); rateLimitError.errorHistory = [ { attempt: 1, error: "Rate limit", retryable: true }, { attempt: 2, error: "Rate limit", retryable: true }, ]; mockShopifyService.executeWithRetry .mockResolvedValueOnce(mockProductsResponse) // Product fetching succeeds .mockRejectedValueOnce(rateLimitError) // First rollback fails with rate limit .mockResolvedValueOnce({ // Retry succeeds productVariantsBulkUpdate: { productVariants: [ { id: "gid://shopify/ProductVariant/456", price: "75.00", compareAtPrice: null, }, ], userErrors: [], }, }); const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); // Should fail due to rate limit error expect(exitCode).toBe(2); // Should not have successful rollback due to rate limit error expect(mockProgressService.logRollbackUpdate).not.toHaveBeenCalled(); }); test("should handle large datasets with pagination", async () => { // Mock first page response const firstPageResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/1", title: "Product 1", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/1", price: "10.00", compareAtPrice: "15.00", title: "Variant 1", }, }, ], }, }, }, ], pageInfo: { hasNextPage: true, endCursor: "cursor1", }, }, }; // Mock second page response const secondPageResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/2", title: "Product 2", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/2", price: "20.00", compareAtPrice: "25.00", title: "Variant 2", }, }, ], }, }, }, ], pageInfo: { hasNextPage: false, endCursor: null, }, }, }; // Mock successful rollback responses const mockRollbackResponse = { productVariantsBulkUpdate: { productVariants: [ { id: "test-variant", price: "15.00", compareAtPrice: null, }, ], userErrors: [], }, }; mockShopifyService.executeWithRetry .mockResolvedValueOnce(firstPageResponse) // First page .mockResolvedValueOnce(secondPageResponse) // Second page .mockResolvedValue(mockRollbackResponse); // All rollback operations const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); expect(exitCode).toBe(0); // Verify both products were processed expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith( expect.objectContaining({ totalProducts: 2, totalVariants: 2, eligibleVariants: 2, successfulRollbacks: 2, failedRollbacks: 0, skippedVariants: 0, }) ); // Verify pagination calls + rollback calls expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(4); // 2 pages + 2 rollbacks }); }); describe("Rollback vs Update Mode Integration", () => { test("should execute update workflow when operation mode is update", async () => { // Change config to update mode mockConfig.operationMode = "update"; getConfig.mockReturnValue(mockConfig); // Mock product fetching and update responses const mockProductsResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/123", title: "Test Product", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/456", price: "50.00", compareAtPrice: null, title: "Variant 1", }, }, ], }, }, }, ], pageInfo: { hasNextPage: false, endCursor: null, }, }, }; const mockUpdateResponse = { productVariantsBulkUpdate: { productVariants: [ { id: "gid://shopify/ProductVariant/456", price: "55.00", compareAtPrice: "50.00", }, ], userErrors: [], }, }; mockShopifyService.executeWithRetry .mockResolvedValueOnce(mockProductsResponse) .mockResolvedValue(mockUpdateResponse); // Mock progress service for update operations mockProgressService.logOperationStart = jest.fn(); mockProgressService.logProductUpdate = jest.fn(); mockProgressService.logCompletionSummary = jest.fn(); const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); expect(exitCode).toBe(0); // Verify update workflow was used, not rollback expect(mockProgressService.logOperationStart).toHaveBeenCalledWith( mockConfig ); expect(mockProgressService.logRollbackStart).not.toHaveBeenCalled(); expect(mockProgressService.logProductUpdate).toHaveBeenCalled(); expect(mockProgressService.logRollbackUpdate).not.toHaveBeenCalled(); expect(mockProgressService.logCompletionSummary).toHaveBeenCalled(); expect(mockProgressService.logRollbackSummary).not.toHaveBeenCalled(); }); }); describe("Error Recovery and Resilience", () => { test("should continue processing after individual variant failures", async () => { const mockProductsResponse = { products: { edges: [ { node: { id: "gid://shopify/Product/123", title: "Test Product", tags: ["rollback-test"], variants: { edges: [ { node: { id: "gid://shopify/ProductVariant/456", price: "50.00", compareAtPrice: "75.00", title: "Success Variant", }, }, { node: { id: "gid://shopify/ProductVariant/789", price: "30.00", compareAtPrice: "40.00", title: "Failure Variant", }, }, { node: { id: "gid://shopify/ProductVariant/101112", price: "20.00", compareAtPrice: "25.00", title: "Another Success Variant", }, }, ], }, }, }, ], pageInfo: { hasNextPage: false, endCursor: null, }, }, }; mockShopifyService.executeWithRetry .mockResolvedValueOnce(mockProductsResponse) // Product fetching .mockResolvedValueOnce({ // First rollback succeeds productVariantsBulkUpdate: { productVariants: [ { id: "gid://shopify/ProductVariant/456", price: "75.00", compareAtPrice: null, }, ], userErrors: [], }, }) .mockResolvedValueOnce({ // Second rollback fails productVariantsBulkUpdate: { productVariants: [], userErrors: [ { field: "price", message: "Invalid price", }, ], }, }) .mockResolvedValueOnce({ // Third rollback succeeds productVariantsBulkUpdate: { productVariants: [ { id: "gid://shopify/ProductVariant/101112", price: "25.00", compareAtPrice: null, }, ], userErrors: [], }, }); const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); expect(exitCode).toBe(1); // Moderate success rate (2/3 = 66.7%) // Verify mixed results expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith( expect.objectContaining({ totalProducts: 1, totalVariants: 3, eligibleVariants: 3, successfulRollbacks: 2, failedRollbacks: 1, skippedVariants: 0, }) ); // Verify both successful operations were logged expect(mockProgressService.logRollbackUpdate).toHaveBeenCalledTimes(2); // Verify error was logged expect(mockProgressService.logError).toHaveBeenCalledWith( expect.objectContaining({ variantId: "gid://shopify/ProductVariant/789", errorMessage: "Shopify API errors: price: Invalid price", }) ); }); test("should stop processing after consecutive errors", async () => { // Create products that will all fail const mockProductsResponse = { products: { edges: Array.from({ length: 10 }, (_, i) => ({ node: { id: `gid://shopify/Product/${i}`, title: `Product ${i}`, tags: ["rollback-test"], variants: { edges: [ { node: { id: `gid://shopify/ProductVariant/${i}`, price: "50.00", compareAtPrice: "75.00", title: `Variant ${i}`, }, }, ], }, }, })), pageInfo: { hasNextPage: false, endCursor: null, }, }, }; // Mock all rollback operations to fail mockShopifyService.executeWithRetry .mockResolvedValueOnce(mockProductsResponse) // Product fetching succeeds .mockRejectedValue(new Error("Persistent API error")); // All rollbacks fail const app = new ShopifyPriceUpdater(); const exitCode = await app.run(); expect(exitCode).toBe(2); // Complete failure // Should stop after 5 consecutive errors const rollbackSummaryCall = mockProgressService.logRollbackSummary.mock.calls[0][0]; expect(rollbackSummaryCall.failedRollbacks).toBeLessThanOrEqual(5); // Should have logged multiple errors (5 individual errors, system error is added to results but not logged separately) expect(mockProgressService.logError).toHaveBeenCalledTimes(5); }); }); });