Files
PriceUpdaterAppv2/tests/integration/rollback-workflow.test.js

798 lines
20 KiB
JavaScript

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