Implemented Rollback Functionality

This commit is contained in:
2025-08-06 15:18:44 -05:00
parent d741dd5466
commit 78818793f2
20 changed files with 6365 additions and 74 deletions

View File

@@ -13,6 +13,7 @@ describe("Environment Configuration", () => {
delete process.env.SHOPIFY_ACCESS_TOKEN;
delete process.env.TARGET_TAG;
delete process.env.PRICE_ADJUSTMENT_PERCENTAGE;
delete process.env.OPERATION_MODE;
});
afterAll(() => {
@@ -35,6 +36,7 @@ describe("Environment Configuration", () => {
accessToken: "shpat_1234567890abcdef",
targetTag: "sale",
priceAdjustmentPercentage: 10,
operationMode: "update",
});
});
@@ -247,5 +249,164 @@ describe("Environment Configuration", () => {
expect(config.targetTag).toBe("sale-2024_special!");
});
});
describe("Operation Mode", () => {
test("should default to 'update' when OPERATION_MODE is not set", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
// OPERATION_MODE is not set
const config = loadEnvironmentConfig();
expect(config.operationMode).toBe("update");
});
test("should accept 'update' operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = "update";
const config = loadEnvironmentConfig();
expect(config.operationMode).toBe("update");
});
test("should accept 'rollback' operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = "rollback";
const config = loadEnvironmentConfig();
expect(config.operationMode).toBe("rollback");
});
test("should throw error for invalid operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = "invalid";
expect(() => loadEnvironmentConfig()).toThrow(
'Invalid OPERATION_MODE: "invalid". Must be either "update" or "rollback".'
);
});
test("should throw error for empty operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = "";
const config = loadEnvironmentConfig();
// Empty string should default to "update"
expect(config.operationMode).toBe("update");
});
test("should handle case sensitivity in operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = "UPDATE";
expect(() => loadEnvironmentConfig()).toThrow(
'Invalid OPERATION_MODE: "UPDATE". Must be either "update" or "rollback".'
);
});
test("should handle whitespace in operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = " rollback ";
expect(() => loadEnvironmentConfig()).toThrow(
'Invalid OPERATION_MODE: " rollback ". Must be either "update" or "rollback".'
);
});
test("should handle null operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = null;
const config = loadEnvironmentConfig();
// Null should default to "update"
expect(config.operationMode).toBe("update");
});
test("should handle undefined operation mode", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = undefined;
const config = loadEnvironmentConfig();
// Undefined should default to "update"
expect(config.operationMode).toBe("update");
});
});
describe("Rollback Mode Specific Validation", () => {
test("should validate rollback mode with all required variables", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
process.env.OPERATION_MODE = "rollback";
const config = loadEnvironmentConfig();
expect(config).toEqual({
shopDomain: "test-shop.myshopify.com",
accessToken: "shpat_1234567890abcdef",
targetTag: "sale",
priceAdjustmentPercentage: 10,
operationMode: "rollback",
});
});
test("should validate rollback mode even with zero percentage", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "0";
process.env.OPERATION_MODE = "rollback";
const config = loadEnvironmentConfig();
expect(config.operationMode).toBe("rollback");
expect(config.priceAdjustmentPercentage).toBe(0);
});
test("should validate rollback mode with negative percentage", () => {
process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
process.env.TARGET_TAG = "sale";
process.env.PRICE_ADJUSTMENT_PERCENTAGE = "-20";
process.env.OPERATION_MODE = "rollback";
const config = loadEnvironmentConfig();
expect(config.operationMode).toBe("rollback");
expect(config.priceAdjustmentPercentage).toBe(-20);
});
});
});
});

686
tests/index.test.js Normal file
View File

@@ -0,0 +1,686 @@
const ShopifyPriceUpdater = require("../src/index");
const { getConfig } = require("../src/config/environment");
const ProductService = require("../src/services/product");
const Logger = require("../src/utils/logger");
// Mock dependencies
jest.mock("../src/config/environment");
jest.mock("../src/services/product");
jest.mock("../src/utils/logger");
describe("ShopifyPriceUpdater - Rollback Functionality", () => {
let app;
let mockConfig;
let mockProductService;
let mockLogger;
beforeEach(() => {
// Mock configuration
mockConfig = {
shopDomain: "test-shop.myshopify.com",
accessToken: "test-token",
targetTag: "test-tag",
priceAdjustmentPercentage: 10,
operationMode: "rollback",
};
// Mock product service
mockProductService = {
shopifyService: {
testConnection: jest.fn(),
},
fetchProductsByTag: jest.fn(),
validateProductsForRollback: jest.fn(),
rollbackProductPrices: jest.fn(),
getProductSummary: jest.fn(),
};
// Mock logger
mockLogger = {
logRollbackStart: jest.fn(),
logOperationStart: jest.fn(),
logProductCount: jest.fn(),
logRollbackSummary: jest.fn(),
logCompletionSummary: jest.fn(),
logErrorAnalysis: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
};
// Mock constructors
getConfig.mockReturnValue(mockConfig);
ProductService.mockImplementation(() => mockProductService);
Logger.mockImplementation(() => mockLogger);
app = new ShopifyPriceUpdater();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("Rollback Mode Initialization", () => {
test("should initialize with rollback configuration", async () => {
const result = await app.initialize();
expect(result).toBe(true);
expect(getConfig).toHaveBeenCalled();
expect(mockLogger.logRollbackStart).toHaveBeenCalledWith(mockConfig);
expect(mockLogger.logOperationStart).not.toHaveBeenCalled();
});
test("should handle initialization failure", async () => {
getConfig.mockImplementation(() => {
throw new Error("Configuration error");
});
const result = await app.initialize();
expect(result).toBe(false);
expect(mockLogger.error).toHaveBeenCalledWith(
"Initialization failed: Configuration error"
);
});
});
describe("Rollback Product Fetching and Validation", () => {
test("should fetch and validate products for rollback", async () => {
const mockProducts = [
{
id: "gid://shopify/Product/123",
title: "Test Product",
variants: [
{
id: "gid://shopify/ProductVariant/456",
price: 50.0,
compareAtPrice: 75.0,
},
],
},
];
const mockEligibleProducts = [mockProducts[0]];
const mockSummary = {
totalProducts: 1,
totalVariants: 1,
priceRange: { min: 50, max: 50 },
};
mockProductService.fetchProductsByTag.mockResolvedValue(mockProducts);
mockProductService.validateProductsForRollback.mockResolvedValue(
mockEligibleProducts
);
mockProductService.getProductSummary.mockReturnValue(mockSummary);
// Initialize app first
await app.initialize();
const result = await app.fetchAndValidateProductsForRollback();
expect(result).toEqual(mockEligibleProducts);
expect(mockProductService.fetchProductsByTag).toHaveBeenCalledWith(
"test-tag"
);
expect(
mockProductService.validateProductsForRollback
).toHaveBeenCalledWith(mockProducts);
expect(mockLogger.logProductCount).toHaveBeenCalledWith(1);
expect(mockLogger.info).toHaveBeenCalledWith("Rollback Product Summary:");
});
test("should handle empty product results", async () => {
mockProductService.fetchProductsByTag.mockResolvedValue([]);
// Initialize app first
await app.initialize();
const result = await app.fetchAndValidateProductsForRollback();
expect(result).toEqual([]);
expect(mockLogger.info).toHaveBeenCalledWith(
"No products found with the specified tag. Operation completed."
);
});
test("should handle product fetching errors", async () => {
mockProductService.fetchProductsByTag.mockRejectedValue(
new Error("API error")
);
// Initialize app first
await app.initialize();
const result = await app.fetchAndValidateProductsForRollback();
expect(result).toBe(null);
expect(mockLogger.error).toHaveBeenCalledWith(
"Failed to fetch products for rollback: API error"
);
});
});
describe("Rollback Price Operations", () => {
test("should execute rollback operations successfully", async () => {
const mockProducts = [
{
id: "gid://shopify/Product/123",
title: "Test Product",
variants: [
{
id: "gid://shopify/ProductVariant/456",
price: 50.0,
compareAtPrice: 75.0,
},
],
},
];
const mockResults = {
totalProducts: 1,
totalVariants: 1,
eligibleVariants: 1,
successfulRollbacks: 1,
failedRollbacks: 0,
skippedVariants: 0,
errors: [],
};
mockProductService.rollbackProductPrices.mockResolvedValue(mockResults);
const result = await app.rollbackPrices(mockProducts);
expect(result).toEqual(mockResults);
expect(mockProductService.rollbackProductPrices).toHaveBeenCalledWith(
mockProducts
);
expect(mockLogger.info).toHaveBeenCalledWith(
"Starting price rollback operations"
);
});
test("should handle empty products array", async () => {
const result = await app.rollbackPrices([]);
expect(result).toEqual({
totalProducts: 0,
totalVariants: 0,
eligibleVariants: 0,
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
errors: [],
});
});
test("should handle rollback operation errors", async () => {
const mockProducts = [
{
id: "gid://shopify/Product/123",
title: "Test Product",
variants: [{ price: 50.0, compareAtPrice: 75.0 }],
},
];
mockProductService.rollbackProductPrices.mockRejectedValue(
new Error("Rollback failed")
);
const result = await app.rollbackPrices(mockProducts);
expect(result).toBe(null);
expect(mockLogger.error).toHaveBeenCalledWith(
"Price rollback failed: Rollback failed"
);
});
});
describe("Rollback Summary Display", () => {
test("should display successful rollback summary", async () => {
const mockResults = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 6,
failedRollbacks: 0,
skippedVariants: 2,
errors: [],
};
app.startTime = new Date();
const exitCode = await app.displayRollbackSummary(mockResults);
expect(exitCode).toBe(0);
expect(mockLogger.logRollbackSummary).toHaveBeenCalledWith(
expect.objectContaining({
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 6,
failedRollbacks: 0,
skippedVariants: 2,
})
);
expect(mockLogger.info).toHaveBeenCalledWith(
"🎉 All rollback operations completed successfully!"
);
});
test("should display partial success rollback summary", async () => {
const mockResults = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 5,
failedRollbacks: 1,
skippedVariants: 2,
errors: [
{
productId: "gid://shopify/Product/123",
errorMessage: "Test error",
},
],
};
app.startTime = new Date();
const exitCode = await app.displayRollbackSummary(mockResults);
expect(exitCode).toBe(1); // Moderate success rate (83.3%)
expect(mockLogger.logRollbackSummary).toHaveBeenCalled();
expect(mockLogger.logErrorAnalysis).toHaveBeenCalledWith(
mockResults.errors,
expect.any(Object)
);
expect(mockLogger.warning).toHaveBeenCalledWith(
expect.stringContaining("moderate success rate")
);
});
test("should display moderate success rollback summary", async () => {
const mockResults = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 3,
failedRollbacks: 3,
skippedVariants: 2,
errors: [
{ productId: "1", errorMessage: "Error 1" },
{ productId: "2", errorMessage: "Error 2" },
{ productId: "3", errorMessage: "Error 3" },
],
};
app.startTime = new Date();
const exitCode = await app.displayRollbackSummary(mockResults);
expect(exitCode).toBe(1); // Moderate success rate (50%)
expect(mockLogger.warning).toHaveBeenCalledWith(
expect.stringContaining("moderate success rate")
);
});
test("should display low success rollback summary", async () => {
const mockResults = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 1,
failedRollbacks: 5,
skippedVariants: 2,
errors: Array.from({ length: 5 }, (_, i) => ({
productId: `${i}`,
errorMessage: `Error ${i}`,
})),
};
app.startTime = new Date();
const exitCode = await app.displayRollbackSummary(mockResults);
expect(exitCode).toBe(2); // Low success rate (16.7%)
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("low success rate")
);
});
test("should display complete failure rollback summary", async () => {
const mockResults = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 0,
failedRollbacks: 6,
skippedVariants: 2,
errors: Array.from({ length: 6 }, (_, i) => ({
productId: `${i}`,
errorMessage: `Error ${i}`,
})),
};
app.startTime = new Date();
const exitCode = await app.displayRollbackSummary(mockResults);
expect(exitCode).toBe(2);
expect(mockLogger.error).toHaveBeenCalledWith(
"❌ All rollback operations failed. Please check your configuration and try again."
);
});
});
describe("Operation Mode Header Display", () => {
test("should display rollback mode header", async () => {
const consoleSpy = jest.spyOn(console, "log").mockImplementation();
// Initialize app first
await app.initialize();
await app.displayOperationModeHeader();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("SHOPIFY PRICE ROLLBACK MODE")
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Reverting prices from compare-at to main price"
)
);
expect(mockLogger.info).toHaveBeenCalledWith("Operation Mode: ROLLBACK");
consoleSpy.mockRestore();
});
test("should display update mode header when not in rollback mode", async () => {
mockConfig.operationMode = "update";
app.config = mockConfig;
const consoleSpy = jest.spyOn(console, "log").mockImplementation();
await app.displayOperationModeHeader();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("SHOPIFY PRICE UPDATE MODE")
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Adjusting prices by 10%")
);
expect(mockLogger.info).toHaveBeenCalledWith("Operation Mode: UPDATE");
consoleSpy.mockRestore();
});
});
describe("Complete Rollback Workflow", () => {
test("should execute complete rollback workflow successfully", async () => {
// Mock successful initialization
mockProductService.shopifyService.testConnection.mockResolvedValue(true);
// Mock successful product fetching and validation
const mockProducts = [
{
id: "gid://shopify/Product/123",
title: "Test Product",
variants: [
{
id: "gid://shopify/ProductVariant/456",
price: 50.0,
compareAtPrice: 75.0,
},
],
},
];
mockProductService.fetchProductsByTag.mockResolvedValue(mockProducts);
mockProductService.validateProductsForRollback.mockResolvedValue(
mockProducts
);
mockProductService.getProductSummary.mockReturnValue({
totalProducts: 1,
totalVariants: 1,
priceRange: { min: 50, max: 50 },
});
// Mock successful rollback
const mockResults = {
totalProducts: 1,
totalVariants: 1,
eligibleVariants: 1,
successfulRollbacks: 1,
failedRollbacks: 0,
skippedVariants: 0,
errors: [],
};
mockProductService.rollbackProductPrices.mockResolvedValue(mockResults);
const exitCode = await app.run();
expect(exitCode).toBe(0);
expect(mockLogger.logRollbackStart).toHaveBeenCalledWith(mockConfig);
expect(mockProductService.fetchProductsByTag).toHaveBeenCalledWith(
"test-tag"
);
expect(
mockProductService.validateProductsForRollback
).toHaveBeenCalledWith(mockProducts);
expect(mockProductService.rollbackProductPrices).toHaveBeenCalledWith(
mockProducts
);
expect(mockLogger.logRollbackSummary).toHaveBeenCalled();
});
test("should handle rollback workflow with initialization failure", async () => {
getConfig.mockImplementation(() => {
throw new Error("Config error");
});
const exitCode = await app.run();
expect(exitCode).toBe(1);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("Initialization failed")
);
});
test("should handle rollback workflow with connection failure", async () => {
mockProductService.shopifyService.testConnection.mockResolvedValue(false);
const exitCode = await app.run();
expect(exitCode).toBe(1);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("API connection failed")
);
});
test("should handle rollback workflow with product fetching failure", async () => {
mockProductService.shopifyService.testConnection.mockResolvedValue(true);
mockProductService.fetchProductsByTag.mockRejectedValue(
new Error("Fetch error")
);
const exitCode = await app.run();
expect(exitCode).toBe(1);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("Product fetching for rollback failed")
);
});
test("should handle rollback workflow with rollback operation failure", async () => {
mockProductService.shopifyService.testConnection.mockResolvedValue(true);
mockProductService.fetchProductsByTag.mockResolvedValue([
{
id: "gid://shopify/Product/123",
variants: [{ price: 50, compareAtPrice: 75 }],
},
]);
mockProductService.validateProductsForRollback.mockResolvedValue([
{
id: "gid://shopify/Product/123",
variants: [{ price: 50, compareAtPrice: 75 }],
},
]);
mockProductService.getProductSummary.mockReturnValue({
totalProducts: 1,
totalVariants: 1,
priceRange: { min: 50, max: 50 },
});
mockProductService.rollbackProductPrices.mockRejectedValue(
new Error("Rollback error")
);
const exitCode = await app.run();
expect(exitCode).toBe(1);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("Price rollback process failed")
);
});
});
describe("Dual Operation Mode Support", () => {
test("should route to update workflow when operation mode is update", async () => {
mockConfig.operationMode = "update";
app.config = mockConfig;
// Mock update-specific methods
app.safeFetchAndValidateProducts = jest.fn().mockResolvedValue([]);
app.safeUpdatePrices = jest.fn().mockResolvedValue({
totalProducts: 0,
totalVariants: 0,
successfulUpdates: 0,
failedUpdates: 0,
errors: [],
});
app.displaySummaryAndGetExitCode = jest.fn().mockResolvedValue(0);
mockProductService.shopifyService.testConnection.mockResolvedValue(true);
const exitCode = await app.run();
expect(exitCode).toBe(0);
expect(mockLogger.logOperationStart).toHaveBeenCalledWith(mockConfig);
expect(mockLogger.logRollbackStart).not.toHaveBeenCalled();
expect(app.safeFetchAndValidateProducts).toHaveBeenCalled();
expect(app.safeUpdatePrices).toHaveBeenCalled();
expect(app.displaySummaryAndGetExitCode).toHaveBeenCalled();
});
test("should route to rollback workflow when operation mode is rollback", async () => {
mockConfig.operationMode = "rollback";
app.config = mockConfig;
mockProductService.shopifyService.testConnection.mockResolvedValue(true);
mockProductService.fetchProductsByTag.mockResolvedValue([]);
mockProductService.validateProductsForRollback.mockResolvedValue([]);
mockProductService.getProductSummary.mockReturnValue({
totalProducts: 0,
totalVariants: 0,
priceRange: { min: 0, max: 0 },
});
const exitCode = await app.run();
expect(exitCode).toBe(0);
expect(mockLogger.logRollbackStart).toHaveBeenCalledWith(mockConfig);
expect(mockLogger.logOperationStart).not.toHaveBeenCalled();
});
});
describe("Error Handling and Recovery", () => {
test("should handle unexpected errors gracefully", async () => {
mockProductService.shopifyService.testConnection.mockResolvedValue(true);
mockProductService.fetchProductsByTag.mockImplementation(() => {
throw new Error("Unexpected error");
});
const exitCode = await app.run();
expect(exitCode).toBe(1); // Critical failure exit code
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining("Product fetching for rollback failed")
);
});
test("should handle critical failures with proper logging", async () => {
// Initialize app first
await app.initialize();
const exitCode = await app.handleCriticalFailure("Test failure", 1);
expect(exitCode).toBe(1);
expect(mockLogger.error).toHaveBeenCalledWith(
"Critical failure in rollback mode: Test failure"
);
expect(mockLogger.logRollbackSummary).toHaveBeenCalledWith(
expect.objectContaining({
totalProducts: 0,
errors: expect.arrayContaining([
expect.objectContaining({
errorMessage: "Test failure",
}),
]),
})
);
});
test("should handle unexpected errors with partial results", async () => {
const partialResults = {
totalProducts: 2,
totalVariants: 3,
eligibleVariants: 2,
successfulRollbacks: 1,
failedRollbacks: 1,
skippedVariants: 1,
errors: [{ errorMessage: "Previous error" }],
};
const error = new Error("Unexpected error");
error.stack = "Error stack trace";
// Initialize app first
await app.initialize();
await app.handleUnexpectedError(error, partialResults);
expect(mockLogger.error).toHaveBeenCalledWith(
"Unexpected error occurred in rollback mode: Unexpected error"
);
expect(mockLogger.logRollbackSummary).toHaveBeenCalledWith(
expect.objectContaining({
totalProducts: 2,
totalVariants: 3,
eligibleVariants: 2,
successfulRollbacks: 1,
failedRollbacks: 1,
skippedVariants: 1,
})
);
});
});
describe("Backward Compatibility", () => {
test("should default to update mode when operation mode is not specified", async () => {
mockConfig.operationMode = "update";
app.config = mockConfig;
// Mock update workflow methods
app.safeFetchAndValidateProducts = jest.fn().mockResolvedValue([]);
app.safeUpdatePrices = jest.fn().mockResolvedValue({
totalProducts: 0,
totalVariants: 0,
successfulUpdates: 0,
failedUpdates: 0,
errors: [],
});
app.displaySummaryAndGetExitCode = jest.fn().mockResolvedValue(0);
mockProductService.shopifyService.testConnection.mockResolvedValue(true);
const exitCode = await app.run();
expect(exitCode).toBe(0);
expect(mockLogger.logOperationStart).toHaveBeenCalledWith(mockConfig);
expect(mockLogger.logRollbackStart).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,797 @@
/**
* 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);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -109,6 +109,47 @@ describe("ProgressService", () => {
});
});
describe("logRollbackStart", () => {
test("should create progress file and log rollback operation start", async () => {
const config = {
targetTag: "rollback-tag",
};
await progressService.logRollbackStart(config);
// Check that file was created
const content = await fs.readFile(testFilePath, "utf8");
expect(content).toContain("# Shopify Price Update Progress Log");
expect(content).toContain("## Price Rollback Operation -");
expect(content).toContain("Target Tag: rollback-tag");
expect(content).toContain("Operation Mode: rollback");
expect(content).toContain("**Configuration:**");
expect(content).toContain("**Progress:**");
});
test("should distinguish rollback from update operations in logs", async () => {
const updateConfig = {
targetTag: "update-tag",
priceAdjustmentPercentage: 10,
};
const rollbackConfig = {
targetTag: "rollback-tag",
};
await progressService.logOperationStart(updateConfig);
await progressService.logRollbackStart(rollbackConfig);
const content = await fs.readFile(testFilePath, "utf8");
expect(content).toContain("## Price Update Operation -");
expect(content).toContain("Price Adjustment: 10%");
expect(content).toContain("## Price Rollback Operation -");
expect(content).toContain("Operation Mode: rollback");
});
});
describe("logProductUpdate", () => {
test("should log successful product update", async () => {
// First create the file
@@ -182,6 +223,97 @@ describe("ProgressService", () => {
});
});
describe("logRollbackUpdate", () => {
test("should log successful rollback operation", async () => {
// First create the file
await progressService.logRollbackStart({
targetTag: "rollback-test",
});
const entry = {
productId: "gid://shopify/Product/123456789",
productTitle: "Rollback Test Product",
variantId: "gid://shopify/ProductVariant/987654321",
oldPrice: 1000.0,
compareAtPrice: 750.0,
newPrice: 750.0,
};
await progressService.logRollbackUpdate(entry);
const content = await fs.readFile(testFilePath, "utf8");
expect(content).toContain(
"🔄 **Rollback Test Product** (gid://shopify/Product/123456789)"
);
expect(content).toContain(
"Variant: gid://shopify/ProductVariant/987654321"
);
expect(content).toContain("Price: $1000 → $750 (from Compare At: $750)");
expect(content).toContain("Rolled back:");
});
test("should distinguish rollback from update entries", async () => {
await progressService.logRollbackStart({
targetTag: "test",
});
const updateEntry = {
productId: "gid://shopify/Product/123",
productTitle: "Update Product",
variantId: "gid://shopify/ProductVariant/456",
oldPrice: 750.0,
newPrice: 1000.0,
};
const rollbackEntry = {
productId: "gid://shopify/Product/789",
productTitle: "Rollback Product",
variantId: "gid://shopify/ProductVariant/012",
oldPrice: 1000.0,
compareAtPrice: 750.0,
newPrice: 750.0,
};
await progressService.logProductUpdate(updateEntry);
await progressService.logRollbackUpdate(rollbackEntry);
const content = await fs.readFile(testFilePath, "utf8");
// Update entry should use checkmark
expect(content).toContain("✅ **Update Product**");
expect(content).toContain("Updated:");
// Rollback entry should use rollback emoji
expect(content).toContain("🔄 **Rollback Product**");
expect(content).toContain("from Compare At:");
expect(content).toContain("Rolled back:");
});
test("should handle products with special characters in rollback", async () => {
await progressService.logRollbackStart({
targetTag: "test",
});
const entry = {
productId: "gid://shopify/Product/123",
productTitle: 'Rollback Product with "Quotes" & Special Chars!',
variantId: "gid://shopify/ProductVariant/456",
oldPrice: 500.0,
compareAtPrice: 400.0,
newPrice: 400.0,
};
await progressService.logRollbackUpdate(entry);
const content = await fs.readFile(testFilePath, "utf8");
expect(content).toContain(
'**Rollback Product with "Quotes" & Special Chars!**'
);
});
});
describe("logError", () => {
test("should log error with all details", async () => {
await progressService.logOperationStart({
@@ -325,6 +457,123 @@ describe("ProgressService", () => {
});
});
describe("logRollbackSummary", () => {
test("should log rollback completion summary with all statistics", async () => {
await progressService.logRollbackStart({
targetTag: "rollback-test",
});
const startTime = new Date(Date.now() - 8000); // 8 seconds ago
const summary = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 5,
failedRollbacks: 1,
skippedVariants: 2,
startTime: startTime,
};
await progressService.logRollbackSummary(summary);
const content = await fs.readFile(testFilePath, "utf8");
expect(content).toContain("**Rollback Summary:**");
expect(content).toContain("Total Products Processed: 5");
expect(content).toContain("Total Variants Processed: 8");
expect(content).toContain("Eligible Variants: 6");
expect(content).toContain("Successful Rollbacks: 5");
expect(content).toContain("Failed Rollbacks: 1");
expect(content).toContain("Skipped Variants: 2 (no compare-at price)");
expect(content).toContain("Duration: 8 seconds");
expect(content).toContain("Completed:");
expect(content).toContain("---");
});
test("should handle rollback summary without start time", async () => {
await progressService.logRollbackStart({
targetTag: "test",
});
const summary = {
totalProducts: 3,
totalVariants: 5,
eligibleVariants: 5,
successfulRollbacks: 5,
failedRollbacks: 0,
skippedVariants: 0,
};
await progressService.logRollbackSummary(summary);
const content = await fs.readFile(testFilePath, "utf8");
expect(content).toContain("Duration: Unknown seconds");
});
test("should distinguish rollback summary from update summary", async () => {
await progressService.logRollbackStart({
targetTag: "test",
});
const updateSummary = {
totalProducts: 5,
successfulUpdates: 4,
failedUpdates: 1,
startTime: new Date(Date.now() - 5000),
};
const rollbackSummary = {
totalProducts: 3,
totalVariants: 6,
eligibleVariants: 4,
successfulRollbacks: 3,
failedRollbacks: 1,
skippedVariants: 2,
startTime: new Date(Date.now() - 3000),
};
await progressService.logCompletionSummary(updateSummary);
await progressService.logRollbackSummary(rollbackSummary);
const content = await fs.readFile(testFilePath, "utf8");
// Should contain both summary types
expect(content).toContain("**Summary:**");
expect(content).toContain("Successful Updates: 4");
expect(content).toContain("**Rollback Summary:**");
expect(content).toContain("Successful Rollbacks: 3");
expect(content).toContain("Skipped Variants: 2 (no compare-at price)");
});
test("should handle zero rollback statistics", async () => {
await progressService.logRollbackStart({
targetTag: "test",
});
const summary = {
totalProducts: 0,
totalVariants: 0,
eligibleVariants: 0,
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
startTime: new Date(),
};
await progressService.logRollbackSummary(summary);
const content = await fs.readFile(testFilePath, "utf8");
expect(content).toContain("Total Products Processed: 0");
expect(content).toContain("Total Variants Processed: 0");
expect(content).toContain("Eligible Variants: 0");
expect(content).toContain("Successful Rollbacks: 0");
expect(content).toContain("Failed Rollbacks: 0");
expect(content).toContain("Skipped Variants: 0");
});
});
describe("categorizeError", () => {
test("should categorize rate limiting errors", () => {
const testCases = [

461
tests/utils/logger.test.js Normal file
View File

@@ -0,0 +1,461 @@
const Logger = require("../../src/utils/logger");
const ProgressService = require("../../src/services/progress");
// Mock the ProgressService
jest.mock("../../src/services/progress");
describe("Logger", () => {
let logger;
let mockProgressService;
let consoleSpy;
beforeEach(() => {
// Create mock progress service
mockProgressService = {
logOperationStart: jest.fn(),
logRollbackStart: jest.fn(),
logProductUpdate: jest.fn(),
logRollbackUpdate: jest.fn(),
logCompletionSummary: jest.fn(),
logRollbackSummary: jest.fn(),
logError: jest.fn(),
logErrorAnalysis: jest.fn(),
};
// Mock the ProgressService constructor
ProgressService.mockImplementation(() => mockProgressService);
logger = new Logger();
// Spy on console methods
consoleSpy = {
log: jest.spyOn(console, "log").mockImplementation(() => {}),
warn: jest.spyOn(console, "warn").mockImplementation(() => {}),
error: jest.spyOn(console, "error").mockImplementation(() => {}),
};
});
afterEach(() => {
jest.clearAllMocks();
consoleSpy.log.mockRestore();
consoleSpy.warn.mockRestore();
consoleSpy.error.mockRestore();
});
describe("Rollback Logging Methods", () => {
describe("logRollbackStart", () => {
it("should log rollback operation start to console and progress file", async () => {
const config = {
targetTag: "test-tag",
shopDomain: "test-shop.myshopify.com",
};
await logger.logRollbackStart(config);
// Check console output
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining(
"Starting price rollback operation with configuration:"
)
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Target Tag: test-tag")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Operation Mode: rollback")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Shop Domain: test-shop.myshopify.com")
);
// Check progress service was called
expect(mockProgressService.logRollbackStart).toHaveBeenCalledWith(
config
);
});
});
describe("logRollbackUpdate", () => {
it("should log successful rollback operations to console and progress file", async () => {
const entry = {
productTitle: "Test Product",
productId: "gid://shopify/Product/123",
variantId: "gid://shopify/ProductVariant/456",
oldPrice: 1000.0,
compareAtPrice: 750.0,
newPrice: 750.0,
};
await logger.logRollbackUpdate(entry);
// Check console output contains rollback-specific formatting
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("🔄")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('Rolled back "Test Product"')
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Price: 1000 → 750")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("from Compare At: 750")
);
// Check progress service was called
expect(mockProgressService.logRollbackUpdate).toHaveBeenCalledWith(
entry
);
});
});
describe("logRollbackSummary", () => {
it("should log rollback completion summary to console and progress file", async () => {
const summary = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 5,
failedRollbacks: 1,
skippedVariants: 2,
startTime: new Date(Date.now() - 30000), // 30 seconds ago
};
await logger.logRollbackSummary(summary);
// Check console output contains rollback-specific formatting
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("ROLLBACK OPERATION COMPLETE")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Total Products Processed: 5")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Total Variants Processed: 8")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Eligible Variants: 6")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Successful Rollbacks: ")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Failed Rollbacks: ")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Skipped Variants: ")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("no compare-at price")
);
// Check progress service was called
expect(mockProgressService.logRollbackSummary).toHaveBeenCalledWith(
summary
);
});
it("should handle zero failed rollbacks without red coloring", async () => {
const summary = {
totalProducts: 3,
totalVariants: 5,
eligibleVariants: 5,
successfulRollbacks: 5,
failedRollbacks: 0,
skippedVariants: 0,
startTime: new Date(Date.now() - 15000),
};
await logger.logRollbackSummary(summary);
// Should show failed rollbacks without red coloring when zero
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Failed Rollbacks: 0")
);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Skipped Variants: 0")
);
});
it("should show colored output for failed rollbacks and skipped variants when greater than zero", async () => {
const summary = {
totalProducts: 5,
totalVariants: 8,
eligibleVariants: 6,
successfulRollbacks: 4,
failedRollbacks: 2,
skippedVariants: 2,
startTime: new Date(Date.now() - 45000),
};
await logger.logRollbackSummary(summary);
// Should show colored output for non-zero values
const logCalls = consoleSpy.log.mock.calls.map((call) => call[0]);
const failedRollbacksCall = logCalls.find((call) =>
call.includes("Failed Rollbacks:")
);
const skippedVariantsCall = logCalls.find((call) =>
call.includes("Skipped Variants:")
);
expect(failedRollbacksCall).toContain("\x1b[31m"); // Red color code
expect(skippedVariantsCall).toContain("\x1b[33m"); // Yellow color code
});
});
});
describe("Rollback vs Update Distinction", () => {
it("should distinguish rollback logs from update logs in console output", async () => {
const updateEntry = {
productTitle: "Test Product",
oldPrice: 750.0,
newPrice: 1000.0,
compareAtPrice: 1000.0,
};
const rollbackEntry = {
productTitle: "Test Product",
oldPrice: 1000.0,
compareAtPrice: 750.0,
newPrice: 750.0,
};
await logger.logProductUpdate(updateEntry);
await logger.logRollbackUpdate(rollbackEntry);
const logCalls = consoleSpy.log.mock.calls.map((call) => call[0]);
// Update should use checkmark emoji
const updateCall = logCalls.find((call) => call.includes("Updated"));
expect(updateCall).toContain("✅");
// Rollback should use rollback emoji
const rollbackCall = logCalls.find((call) =>
call.includes("Rolled back")
);
expect(rollbackCall).toContain("🔄");
});
it("should call different progress service methods for updates vs rollbacks", async () => {
const updateEntry = {
productTitle: "Test",
oldPrice: 750,
newPrice: 1000,
};
const rollbackEntry = {
productTitle: "Test",
oldPrice: 1000,
newPrice: 750,
compareAtPrice: 750,
};
await logger.logProductUpdate(updateEntry);
await logger.logRollbackUpdate(rollbackEntry);
expect(mockProgressService.logProductUpdate).toHaveBeenCalledWith(
updateEntry
);
expect(mockProgressService.logRollbackUpdate).toHaveBeenCalledWith(
rollbackEntry
);
});
});
describe("Error Handling", () => {
it("should handle progress service errors gracefully", async () => {
mockProgressService.logRollbackStart.mockRejectedValue(
new Error("Progress service error")
);
const config = {
targetTag: "test-tag",
shopDomain: "test-shop.myshopify.com",
};
// Should not throw even if progress service fails
await expect(logger.logRollbackStart(config)).resolves.not.toThrow();
// Console output should still work
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Starting price rollback operation")
);
});
it("should handle rollback update logging errors gracefully", async () => {
mockProgressService.logRollbackUpdate.mockRejectedValue(
new Error("Progress service error")
);
const entry = {
productTitle: "Test Product",
oldPrice: 1000,
newPrice: 750,
compareAtPrice: 750,
};
// Should not throw even if progress service fails
await expect(logger.logRollbackUpdate(entry)).resolves.not.toThrow();
// Console output should still work
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Rolled back")
);
});
it("should handle rollback summary logging errors gracefully", async () => {
mockProgressService.logRollbackSummary.mockRejectedValue(
new Error("Progress service error")
);
const summary = {
totalProducts: 5,
successfulRollbacks: 4,
failedRollbacks: 1,
skippedVariants: 0,
startTime: new Date(),
};
// Should not throw even if progress service fails
await expect(logger.logRollbackSummary(summary)).resolves.not.toThrow();
// Console output should still work
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("ROLLBACK OPERATION COMPLETE")
);
});
});
describe("Existing Logger Methods", () => {
describe("Basic logging methods", () => {
it("should log info messages to console", async () => {
await logger.info("Test info message");
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Test info message")
);
});
it("should log warning messages to console", async () => {
await logger.warning("Test warning message");
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("Test warning message")
);
});
it("should log error messages to console", async () => {
await logger.error("Test error message");
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Test error message")
);
});
});
describe("Operation start logging", () => {
it("should log operation start for update mode", async () => {
const config = {
targetTag: "test-tag",
priceAdjustmentPercentage: 10,
shopDomain: "test-shop.myshopify.com",
};
await logger.logOperationStart(config);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Starting price update operation")
);
expect(mockProgressService.logOperationStart).toHaveBeenCalledWith(
config
);
});
});
describe("Product update logging", () => {
it("should log product updates", async () => {
const entry = {
productTitle: "Test Product",
oldPrice: 100,
newPrice: 110,
compareAtPrice: 100,
};
await logger.logProductUpdate(entry);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Updated")
);
expect(mockProgressService.logProductUpdate).toHaveBeenCalledWith(
entry
);
});
});
describe("Completion summary logging", () => {
it("should log completion summary", async () => {
const summary = {
totalProducts: 5,
successfulUpdates: 4,
failedUpdates: 1,
startTime: new Date(),
};
await logger.logCompletionSummary(summary);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("OPERATION COMPLETE")
);
expect(mockProgressService.logCompletionSummary).toHaveBeenCalledWith(
summary
);
});
});
describe("Error logging", () => {
it("should log product errors", async () => {
const errorEntry = {
productTitle: "Test Product",
errorMessage: "Test error",
};
await logger.logProductError(errorEntry);
expect(mockProgressService.logError).toHaveBeenCalledWith(errorEntry);
});
it("should log error analysis", async () => {
const errors = [
{ errorMessage: "Error 1" },
{ errorMessage: "Error 2" },
];
const summary = { totalProducts: 2 };
await logger.logErrorAnalysis(errors, summary);
expect(mockProgressService.logErrorAnalysis).toHaveBeenCalledWith(
errors,
summary
);
});
});
describe("Product count logging", () => {
it("should log product count", async () => {
await logger.logProductCount(5);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Found 5 products")
);
});
it("should handle zero products", async () => {
await logger.logProductCount(0);
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Found 0 products")
);
});
});
});
});

View File

@@ -5,6 +5,8 @@ const {
calculatePercentageChange,
isValidPercentage,
preparePriceUpdate,
validateRollbackEligibility,
prepareRollbackUpdate,
} = require("../../src/utils/price");
describe("Price Utilities", () => {
@@ -260,4 +262,315 @@ describe("Price Utilities", () => {
);
});
});
describe("validateRollbackEligibility", () => {
test("should return eligible for valid variant with different prices", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "75.00",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(true);
expect(result.variant.id).toBe("gid://shopify/ProductVariant/123");
expect(result.variant.currentPrice).toBe(50.0);
expect(result.variant.compareAtPrice).toBe(75.0);
expect(result.reason).toBeUndefined();
});
test("should return not eligible when variant is null or undefined", () => {
expect(validateRollbackEligibility(null).isEligible).toBe(false);
expect(validateRollbackEligibility(null).reason).toBe(
"Invalid variant object"
);
expect(validateRollbackEligibility(undefined).isEligible).toBe(false);
expect(validateRollbackEligibility(undefined).reason).toBe(
"Invalid variant object"
);
});
test("should return not eligible when variant is not an object", () => {
expect(validateRollbackEligibility("invalid").isEligible).toBe(false);
expect(validateRollbackEligibility("invalid").reason).toBe(
"Invalid variant object"
);
expect(validateRollbackEligibility(123).isEligible).toBe(false);
expect(validateRollbackEligibility(123).reason).toBe(
"Invalid variant object"
);
});
test("should return not eligible when current price is invalid", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "invalid",
compareAtPrice: "75.00",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe("Invalid current price");
expect(result.variant.currentPrice).toBeNaN();
});
test("should return not eligible when current price is negative", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "-10.00",
compareAtPrice: "75.00",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe("Invalid current price");
expect(result.variant.currentPrice).toBe(-10.0);
});
test("should return not eligible when compare-at price is null", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: null,
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe("No compare-at price available");
expect(result.variant.compareAtPrice).toBe(null);
});
test("should return not eligible when compare-at price is undefined", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
// compareAtPrice is undefined
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe("No compare-at price available");
expect(result.variant.compareAtPrice).toBe(null);
});
test("should return not eligible when compare-at price is invalid", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "invalid",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe("Invalid compare-at price");
expect(result.variant.compareAtPrice).toBeNaN();
});
test("should return not eligible when compare-at price is zero", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "0.00",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe("Compare-at price must be greater than zero");
expect(result.variant.compareAtPrice).toBe(0.0);
});
test("should return not eligible when compare-at price is negative", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "-10.00",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe("Compare-at price must be greater than zero");
expect(result.variant.compareAtPrice).toBe(-10.0);
});
test("should return not eligible when prices are the same", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "50.00",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe(
"Compare-at price is the same as current price"
);
expect(result.variant.currentPrice).toBe(50.0);
expect(result.variant.compareAtPrice).toBe(50.0);
});
test("should return not eligible when prices are nearly the same (within epsilon)", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "50.005", // Within 0.01 epsilon
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(false);
expect(result.reason).toBe(
"Compare-at price is the same as current price"
);
});
test("should handle numeric price values", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: 50.0,
compareAtPrice: 75.0,
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(true);
expect(result.variant.currentPrice).toBe(50.0);
expect(result.variant.compareAtPrice).toBe(75.0);
});
test("should handle decimal prices correctly", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "29.99",
compareAtPrice: "39.99",
};
const result = validateRollbackEligibility(variant);
expect(result.isEligible).toBe(true);
expect(result.variant.currentPrice).toBe(29.99);
expect(result.variant.compareAtPrice).toBe(39.99);
});
});
describe("prepareRollbackUpdate", () => {
test("should prepare rollback update for eligible variant", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "75.00",
};
const result = prepareRollbackUpdate(variant);
expect(result.newPrice).toBe(75.0);
expect(result.compareAtPrice).toBe(null);
});
test("should prepare rollback update with decimal prices", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "29.99",
compareAtPrice: "39.99",
};
const result = prepareRollbackUpdate(variant);
expect(result.newPrice).toBe(39.99);
expect(result.compareAtPrice).toBe(null);
});
test("should handle numeric price values", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: 25.5,
compareAtPrice: 35.75,
};
const result = prepareRollbackUpdate(variant);
expect(result.newPrice).toBe(35.75);
expect(result.compareAtPrice).toBe(null);
});
test("should throw error for variant without compare-at price", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: null,
};
expect(() => prepareRollbackUpdate(variant)).toThrow(
"Cannot prepare rollback update: No compare-at price available"
);
});
test("should throw error for variant with invalid compare-at price", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "invalid",
};
expect(() => prepareRollbackUpdate(variant)).toThrow(
"Cannot prepare rollback update: Invalid compare-at price"
);
});
test("should throw error for variant with zero compare-at price", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "0.00",
};
expect(() => prepareRollbackUpdate(variant)).toThrow(
"Cannot prepare rollback update: Compare-at price must be greater than zero"
);
});
test("should throw error for variant with same prices", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "50.00",
compareAtPrice: "50.00",
};
expect(() => prepareRollbackUpdate(variant)).toThrow(
"Cannot prepare rollback update: Compare-at price is the same as current price"
);
});
test("should throw error for invalid variant object", () => {
expect(() => prepareRollbackUpdate(null)).toThrow(
"Cannot prepare rollback update: Invalid variant object"
);
expect(() => prepareRollbackUpdate("invalid")).toThrow(
"Cannot prepare rollback update: Invalid variant object"
);
});
test("should throw error for variant with invalid current price", () => {
const variant = {
id: "gid://shopify/ProductVariant/123",
price: "invalid",
compareAtPrice: "75.00",
};
expect(() => prepareRollbackUpdate(variant)).toThrow(
"Cannot prepare rollback update: Invalid current price"
);
});
});
});