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

@@ -6,6 +6,12 @@ SHOPIFY_ACCESS_TOKEN=your-admin-api-access-token
TARGET_TAG=sale
PRICE_ADJUSTMENT_PERCENTAGE=10
# Operation Mode Configuration
# OPERATION_MODE determines whether to update prices or rollback to compare-at prices
# Options: "update" (default) or "rollback"
OPERATION_MODE=update
# Optional Configuration
# PRICE_ADJUSTMENT_PERCENTAGE can be positive (increase) or negative (decrease)
# Example: 10 = 10% increase, -15 = 15% decrease
# Note: PRICE_ADJUSTMENT_PERCENTAGE is only used in "update" mode

View File

@@ -0,0 +1,390 @@
# Design Document
## Overview
The Price Rollback feature extends the existing Shopify Price Updater application to support reversing pricing changes by setting the main product price to the current compare-at price and removing the compare-at price. This functionality will be integrated into the existing application architecture, providing dual operation modes (price update and rollback) within the same codebase and infrastructure.
## Architecture
The rollback functionality will be integrated into the existing modular architecture with minimal changes:
```
shopify-price-updater/
├── src/
│ ├── config/
│ │ └── environment.js # Extended to support operation mode configuration
│ ├── services/
│ │ ├── shopify.js # Existing Shopify API client (no changes)
│ │ ├── product.js # Extended with rollback methods
│ │ └── progress.js # Extended to log rollback operations
│ ├── utils/
│ │ ├── price.js # Extended with rollback price utilities
│ │ └── logger.js # Extended to distinguish operation types
│ └── index.js # Extended to support dual operation modes
├── .env.example # Updated with operation mode variable
├── package.json # Updated scripts for rollback mode
└── Progress.md # Contains both update and rollback logs
```
## Operation Mode Selection
The application will determine its operation mode through environment configuration:
### Environment Variable
- `OPERATION_MODE`: Controls which operation to perform
- `"update"` (default): Performs price updates with percentage adjustments
- `"rollback"`: Performs price rollbacks using compare-at prices
### Backward Compatibility
- If `OPERATION_MODE` is not specified, defaults to `"update"` mode
- All existing environment variables remain unchanged
- Existing scripts and workflows continue to work without modification
## Components and Interfaces
### Environment Configuration (`config/environment.js`)
**Extensions:**
- Add `OPERATION_MODE` environment variable loading and validation
- Validate operation mode is either "update" or "rollback"
- Export operation mode in configuration object
- Maintain backward compatibility when operation mode is not specified
**New Configuration Object:**
```javascript
{
shopDomain: string,
accessToken: string,
targetTag: string,
priceAdjustmentPercentage: number, // Only used in update mode
operationMode: 'update' | 'rollback' // New field
}
```
### Product Service (`services/product.js`)
**New Methods:**
1. **`validateProductsForRollback(products)`**
- Validates products have variants with compare-at prices
- Skips products/variants without compare-at prices
- Logs validation warnings for products that cannot be rolled back
- Returns array of products with rollback-eligible variants
2. **`rollbackProductPrices(products)`**
- Main rollback orchestration method
- Processes products in batches to manage rate limits
- Returns results object with rollback statistics
- Logs progress and errors during rollback operations
3. **`processProductForRollback(product, results)`**
- Processes individual product for rollback
- Iterates through variants and performs rollback operations
- Updates results object with success/failure counts
- Handles variant-level error logging
4. **`rollbackVariantPrice(variant, productId)`**
- Updates individual variant price to compare-at price value
- Sets compare-at price to null (removes it)
- Uses existing GraphQL mutation infrastructure
- Returns success/failure result with error details
**GraphQL Mutation for Rollback:**
```graphql
mutation productVariantsBulkUpdate(
$productId: ID!
$variants: [ProductVariantsBulkInput!]!
) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants {
id
price
compareAtPrice
}
userErrors {
field
message
}
}
}
```
**Rollback Mutation Variables:**
```javascript
{
productId: "gid://shopify/Product/123",
variants: [{
id: "gid://shopify/ProductVariant/456",
price: "750.00", // Set to current compare-at price
compareAtPrice: null // Remove compare-at price
}]
}
```
### Price Utilities (`utils/price.js`)
**New Functions:**
1. **`validateRollbackEligibility(variant)`**
- Checks if variant has a valid compare-at price
- Validates compare-at price is different from current price
- Returns boolean indicating rollback eligibility
- Provides reason for ineligibility if applicable
2. **`prepareRollbackUpdate(variant)`**
- Prepares price update object for rollback operation
- Sets new price to current compare-at price value
- Sets compare-at price to null
- Validates the rollback operation is safe to perform
**Example Usage:**
```javascript
// Check if variant can be rolled back
const isEligible = validateRollbackEligibility(variant);
// Prepare rollback update
const rollbackUpdate = prepareRollbackUpdate(variant);
// Returns: { newPrice: 750.00, compareAtPrice: null }
```
### Logger Utilities (`utils/logger.js`)
**Extensions:**
1. **`logRollbackStart(config)`**
- Logs rollback operation start with configuration
- Distinguishes from price update operations in logs
- Includes operation mode in log entries
2. **`logRollbackUpdate(updateDetails)`**
- Logs successful rollback operations
- Includes original price, compare-at price, and new price
- Formats specifically for rollback operations
3. **`logRollbackSummary(results)`**
- Logs rollback completion summary
- Includes rollback-specific statistics
- Distinguishes from price update summaries
### Main Application (`index.js`)
**Extensions:**
1. **Operation Mode Detection:**
- Read operation mode from configuration
- Route to appropriate workflow based on mode
- Maintain single entry point for both operations
2. **Rollback Workflow Methods:**
- `fetchAndValidateProductsForRollback()`: Fetch and validate products for rollback
- `rollbackPrices(products)`: Execute rollback operations
- `displayRollbackSummary(results)`: Display rollback-specific summary
3. **Unified Error Handling:**
- Extend existing error handling to support rollback operations
- Maintain consistent error reporting across both modes
- Preserve existing signal handling and graceful shutdown
## Data Models
### Rollback Validation Result
```javascript
{
isEligible: boolean,
reason?: string, // Present if not eligible
variant: {
id: string,
currentPrice: number,
compareAtPrice: number | null
}
}
```
### Rollback Update Object
```javascript
{
newPrice: number, // Set to current compare-at price
compareAtPrice: null // Always null to remove compare-at price
}
```
### Rollback Results
```javascript
{
totalProducts: number,
totalVariants: number,
eligibleVariants: number, // New field for rollback
successfulRollbacks: number,
failedRollbacks: number,
skippedVariants: number, // Variants without compare-at prices
errors: Array<{
productId: string,
productTitle: string,
variantId: string,
errorMessage: string
}>
}
```
### Progress Entry for Rollback
```javascript
{
timestamp: Date,
operationType: 'rollback', // Distinguishes from 'update'
productId: string,
productTitle: string,
variantId: string,
oldPrice: number, // Original price before rollback
compareAtPrice: number, // Compare-at price being used as new price
newPrice: number, // New price (same as compare-at price)
status: 'success' | 'error' | 'skipped',
errorMessage?: string,
skipReason?: string // For variants without compare-at prices
}
```
## Error Handling
### Rollback-Specific Error Scenarios
1. **No Compare-At Price:**
- Skip variant and log warning
- Continue processing other variants
- Include in skipped count for summary
2. **Invalid Compare-At Price:**
- Skip variant if compare-at price is null, zero, or negative
- Log validation warning with specific reason
- Continue processing other variants
3. **Same Price Values:**
- Skip variant if current price equals compare-at price
- Log informational message (no rollback needed)
- Continue processing other variants
4. **API Update Failures:**
- Use existing retry logic for rate limiting
- Log specific error messages from Shopify API
- Continue processing remaining variants
### Enhanced Error Reporting
- Distinguish rollback errors from update errors in logs
- Provide rollback-specific error analysis in completion summary
- Include skipped variant statistics in error reporting
- Maintain existing error handling patterns for consistency
## Testing Strategy
### Unit Tests
**New Test Cases:**
- Test rollback price calculation utilities
- Test rollback eligibility validation
- Test rollback update object preparation
- Test operation mode configuration loading
**Extended Test Cases:**
- Test product service rollback methods
- Test logger rollback-specific methods
- Test main application rollback workflow
### Integration Tests
**New Test Scenarios:**
- Test rollback operations with development store
- Test rollback with products having various compare-at price scenarios
- Test rollback mode selection and configuration
- Test mixed scenarios (some variants eligible, some not)
**Extended Test Scenarios:**
- Test dual operation mode functionality
- Test backward compatibility with existing configurations
- Test progress logging for rollback operations
### End-to-End Tests
**Rollback Workflow Tests:**
- Test complete rollback workflow with test products
- Verify prices are correctly set to compare-at values
- Verify compare-at prices are removed after rollback
- Test error recovery and continuation scenarios
**Dual Mode Tests:**
- Test switching between update and rollback modes
- Test that both modes use same infrastructure correctly
- Verify progress logging distinguishes between operations
### Manual Testing Checklist
**Rollback Functionality:**
- Test rollback with products having compare-at prices
- Test rollback with products missing compare-at prices
- Test rollback with mixed variant scenarios
- Verify Progress.md logging for rollback operations
**Integration Testing:**
- Test backward compatibility with existing configurations
- Test operation mode switching
- Test that existing update functionality remains unchanged
- Verify error handling consistency across both modes
## Implementation Considerations
### Code Reuse Strategy
- Maximize reuse of existing Shopify API infrastructure
- Extend existing classes rather than creating new ones
- Maintain consistent error handling patterns
- Preserve existing logging and progress tracking mechanisms
### Performance Considerations
- Use same batching strategy as existing price updates
- Apply same rate limiting and retry logic
- Maintain existing pagination patterns for product fetching
- Preserve existing delay mechanisms between operations
### Backward Compatibility
- Default to existing behavior when operation mode not specified
- Maintain all existing environment variables and their behavior
- Preserve existing script entry points and command line usage
- Ensure existing Progress.md format remains compatible
### Configuration Management
- Add new environment variable with sensible default
- Validate operation mode values during configuration loading
- Provide clear error messages for invalid operation modes
- Update .env.example with new configuration option

View File

@@ -0,0 +1,117 @@
# Requirements Document
## Introduction
This feature involves adding a rollback functionality to the existing Shopify price updater application that reverses pricing changes by setting the main product price to the current compare-at price and removing the compare-at price. This allows store administrators to easily revert promotional pricing back to original prices for products filtered by specific tags. The solution will extend the existing Shopify GraphQL API infrastructure and provide both price updating and rollback capabilities within the same application.
## Requirements
### Requirement 1
**User Story:** As a store administrator, I want to rollback product prices for products with specific tags, so that I can easily revert promotional pricing to original prices.
#### Acceptance Criteria
1. WHEN the rollback script is executed THEN the system SHALL connect to Shopify using GraphQL API
2. WHEN connecting to Shopify THEN the system SHALL authenticate using existing API credentials from environment variables
3. WHEN querying products THEN the system SHALL filter products by a configurable tag
4. WHEN a matching product is found THEN the system SHALL check if it has a compare-at price
5. WHEN a product has a compare-at price THEN the system SHALL set the main price to the compare-at price value
6. WHEN setting the new price THEN the system SHALL remove the compare-at price (set to null)
7. WHEN a product has no compare-at price THEN the system SHALL skip the product and log a warning
### Requirement 2
**User Story:** As a store administrator, I want to configure the rollback script through environment variables, so that I can easily specify which products to rollback without modifying code.
#### Acceptance Criteria
1. WHEN the rollback script starts THEN the system SHALL load Shopify API credentials from existing .env file
2. WHEN loading configuration THEN the system SHALL read the target product tag from environment variables
3. IF required environment variables are missing THEN the system SHALL display an error message and exit gracefully
4. WHEN the tag is specified THEN the system SHALL use it to filter products for rollback
5. WHEN no tag is specified THEN the system SHALL display an error and require tag specification
### Requirement 3
**User Story:** As a store administrator, I want to see detailed feedback about the rollback process, so that I can verify the changes were applied correctly.
#### Acceptance Criteria
1. WHEN the rollback script starts THEN the system SHALL display the configuration being used
2. WHEN products are found THEN the system SHALL display the count of matching products
3. WHEN rolling back each product THEN the system SHALL log the product name, current price, compare-at price, and new price
4. WHEN a product is skipped THEN the system SHALL log the reason (no compare-at price)
5. WHEN all rollbacks are complete THEN the system SHALL display a summary of total products updated and skipped
### Requirement 4
**User Story:** As a store administrator, I want the rollback script to handle errors gracefully, so that partial failures don't prevent other products from being rolled back.
#### Acceptance Criteria
1. WHEN API rate limits are encountered THEN the system SHALL implement appropriate retry logic
2. WHEN a product rollback fails THEN the system SHALL log the error and continue with remaining products
3. WHEN network errors occur THEN the system SHALL retry the operation with exponential backoff
4. WHEN invalid product data is encountered THEN the system SHALL skip the product and log a warning
5. WHEN the script completes THEN the system SHALL exit with appropriate status codes
### Requirement 5
**User Story:** As a developer, I want the rollback script to reuse existing Shopify API infrastructure, so that I can maintain consistency and reduce code duplication.
#### Acceptance Criteria
1. WHEN implementing the rollback solution THEN the system SHALL use existing Shopify service components
2. WHEN querying products THEN the system SHALL use existing GraphQL query patterns
3. WHEN updating products THEN the system SHALL use existing GraphQL mutation infrastructure
4. WHEN handling API responses THEN the system SHALL reuse existing error handling patterns
5. WHEN managing API connections THEN the system SHALL use existing authentication and session management
### Requirement 6
**User Story:** As a store administrator, I want the rollback script to validate data before making changes, so that I can avoid unintended price modifications.
#### Acceptance Criteria
1. WHEN processing a product THEN the system SHALL verify the product has variants with compare-at prices
2. WHEN a compare-at price exists THEN the system SHALL validate it is a positive number
3. WHEN rolling back prices THEN the system SHALL ensure the compare-at price is different from current price
4. WHEN a product variant has no compare-at price THEN the system SHALL skip that variant and log the reason
5. WHEN validation fails THEN the system SHALL skip the product and continue processing others
### Requirement 7
**User Story:** As a developer, I want the rollback script to maintain a progress log, so that I can track what rollback operations have been completed and reference them later.
#### Acceptance Criteria
1. WHEN the rollback script starts THEN the system SHALL create or append to a Progress.md file
2. WHEN each product is processed THEN the system SHALL log the rollback operation details to Progress.md
3. WHEN the script completes THEN the system SHALL write a summary to Progress.md with timestamp
4. WHEN errors occur THEN the system SHALL log error details to Progress.md for debugging
5. WHEN the script runs multiple times THEN the system SHALL append new progress entries without overwriting previous runs
### Requirement 8
**User Story:** As a store administrator, I want to run the rollback as an additional mode within the existing application, so that I can use both price updating and rollback functionality from the same tool.
#### Acceptance Criteria
1. WHEN running the application THEN the system SHALL support both price update and rollback modes
2. WHEN rollback mode is specified THEN the system SHALL perform rollback operations instead of price updates
3. WHEN logging progress THEN the system SHALL distinguish rollback entries from regular price update entries in the same Progress.md file
4. WHEN displaying console output THEN the system SHALL use clear messaging that indicates which operation mode is active
5. WHEN the rollback completes THEN the system SHALL provide a summary specific to rollback operations while maintaining compatibility with existing functionality
### Requirement 9
**User Story:** As a store administrator, I want to choose between price update and rollback modes when running the application, so that I can perform both operations using the same tool and configuration.
#### Acceptance Criteria
1. WHEN starting the application THEN the system SHALL determine the operation mode based on configuration or command line parameters
2. WHEN price update mode is selected THEN the system SHALL perform the existing price adjustment functionality
3. WHEN rollback mode is selected THEN the system SHALL perform the rollback functionality
4. WHEN no mode is specified THEN the system SHALL default to price update mode for backward compatibility
5. WHEN either mode is executed THEN the system SHALL use the same environment configuration and Shopify API infrastructure

View File

@@ -0,0 +1,86 @@
# Implementation Plan
- [x] 1. Extend environment configuration to support operation mode
- Add OPERATION_MODE environment variable loading and validation in config/environment.js
- Set default operation mode to "update" for backward compatibility
- Validate operation mode is either "update" or "rollback"
- Update .env.example file with new OPERATION_MODE variable
- _Requirements: 2.1, 2.2, 9.4, 9.5_
- [x] 2. Add rollback price calculation utilities
- Create validateRollbackEligibility function in utils/price.js to check if variant has valid compare-at price
- Create prepareRollbackUpdate function in utils/price.js to prepare rollback price update objects
- Add unit tests for rollback price utility functions
- _Requirements: 6.1, 6.2, 6.3, 6.4_
- [x] 3. Extend product service with rollback validation methods
- Add validateProductsForRollback method to services/product.js to filter products eligible for rollback
- Implement logic to skip products/variants without compare-at prices
- Add logging for validation warnings when products cannot be rolled back
- Write unit tests for rollback validation methods
- _Requirements: 1.7, 6.1, 6.4, 6.5_
- [x] 4. Implement core rollback functionality in product service
- Add rollbackVariantPrice method to services/product.js to update individual variant prices
- Implement rollbackProductPrices method to orchestrate batch rollback operations
- Add processProductForRollback method to handle individual product rollback processing
- Use existing GraphQL mutation infrastructure for price updates
- _Requirements: 1.4, 1.5, 1.6, 5.1, 5.2, 5.3_
- [x] 5. Extend logging utilities for rollback operations
- Add logRollbackStart method to utils/logger.js to log rollback operation initialization
- Add logRollbackUpdate method to utils/logger.js to log successful rollback operations
- Add logRollbackSummary method to utils/logger.js for rollback completion summaries
- Ensure rollback logs are distinguished from price update logs in Progress.md
- _Requirements: 3.1, 3.3, 3.5, 7.1, 7.2, 7.3, 8.3_
- [x] 6. Implement rollback workflow in main application
- Add operation mode detection logic to src/index.js based on configuration
- Create fetchAndValidateProductsForRollback method in main application class
- Create rollbackPrices method to execute rollback operations
- Create displayRollbackSummary method for rollback-specific result display
- _Requirements: 8.1, 8.2, 8.4, 8.5, 9.1, 9.2, 9.3_
- [x] 7. Integrate dual operation mode routing
- Modify main application run method to route between update and rollback workflows
- Ensure both operation modes use the same error handling and infrastructure
- Maintain backward compatibility when operation mode is not specified
- Add operation mode indication in console output and logging
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
- [x] 8. Add comprehensive error handling for rollback operations
- Implement graceful handling of products without compare-at prices
- Add retry logic for rollback API operations using existing patterns
- Ensure rollback errors are properly logged and don't stop processing of other products
- Add rollback-specific error analysis in completion summaries
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
- [x] 9. Update package.json scripts for rollback mode
- Add npm script for running rollback operations
- Update existing scripts to maintain backward compatibility
- Add script documentation for both operation modes
- _Requirements: 8.1, 8.2_
- [x] 10. Create comprehensive tests for rollback functionality
- Write unit tests for all new rollback utility functions
- Write integration tests for rollback product service methods
- Write end-to-end tests for complete rollback workflow
- Test dual operation mode functionality and backward compatibility
- _Requirements: All requirements - comprehensive testing coverage_
- [x] 11. Update documentation and examples
- Update .env.example with OPERATION_MODE variable and usage examples
- Add rollback operation examples and usage instructions
- Update any existing documentation to reflect dual operation mode capability
- _Requirements: 2.1, 8.4, 9.4_

View File

@@ -342,3 +342,243 @@ This file tracks the progress of price update operations.
---
## Price Update Operation - 2025-08-06 19:30:21 UTC
**Configuration:**
- Target Tag: summer-sale
- Price Adjustment: 10%
- Started: 2025-08-06 19:30:21 UTC
**Progress:**
-**The Hidden Snowboard** (gid://shopify/Product/8116504920355)
- Variant: gid://shopify/ProductVariant/44236769788195
- Price: $749.99 → $824.99
- Compare At Price: $749.99
- Updated: 2025-08-06 19:30:22 UTC
**Summary:**
- Total Products Processed: 1
- Successful Updates: 1
- Failed Updates: 0
- Duration: 1 seconds
- Completed: 2025-08-06 19:30:22 UTC
---
## Price Rollback Operation - 2025-08-06 19:30:51 UTC
**Configuration:**
- Target Tag: summer-sale
- Operation Mode: rollback
- Started: 2025-08-06 19:30:51 UTC
**Progress:**
-**The Hidden Snowboard** (gid://shopify/Product/8116504920355)
- Variant: gid://shopify/ProductVariant/44236769788195
- Price: $824.99 → $749.99
- Compare At Price: $749.99
- Updated: 2025-08-06 19:30:52 UTC
**Rollback Summary:**
- Total Products Processed: 1
- Total Variants Processed: 1
- Eligible Variants: 1
- Successful Rollbacks: 1
- Failed Rollbacks: 0
- Skipped Variants: 0 (no compare-at price)
- Duration: 1 seconds
- Completed: 2025-08-06 19:30:52 UTC
---
## Price Rollback Operation - 2025-08-06 19:30:58 UTC
**Configuration:**
- Target Tag: summer-sale
- Operation Mode: rollback
- Started: 2025-08-06 19:30:58 UTC
**Progress:**
**Rollback Summary:**
- Total Products Processed: 0
- Total Variants Processed: 0
- Eligible Variants: 0
- Successful Rollbacks: 0
- Failed Rollbacks: 0
- Skipped Variants: 0 (no compare-at price)
- Duration: 0 seconds
- Completed: 2025-08-06 19:30:58 UTC
---
## Price Update Operation - 2025-08-06 19:42:29 UTC
**Configuration:**
- Target Tag: summer-sale
- Price Adjustment: 10%
- Started: 2025-08-06 19:42:29 UTC
**Progress:**
-**The Hidden Snowboard** (gid://shopify/Product/8116504920355)
- Variant: gid://shopify/ProductVariant/44236769788195
- Price: $749.99 → $824.99
- Compare At Price: $749.99
- Updated: 2025-08-06 19:42:30 UTC
**Summary:**
- Total Products Processed: 1
- Successful Updates: 1
- Failed Updates: 0
- Duration: 1 seconds
- Completed: 2025-08-06 19:42:30 UTC
---
## Price Rollback Operation - 2025-08-06 19:42:43 UTC
**Configuration:**
- Target Tag: summer-sale
- Operation Mode: rollback
- Started: 2025-08-06 19:42:43 UTC
**Progress:**
-**The Hidden Snowboard** (gid://shopify/Product/8116504920355)
- Variant: gid://shopify/ProductVariant/44236769788195
- Price: $824.99 → $749.99
- Compare At Price: $749.99
- Updated: 2025-08-06 19:42:44 UTC
**Rollback Summary:**
- Total Products Processed: 1
- Total Variants Processed: 1
- Eligible Variants: 1
- Successful Rollbacks: 1
- Failed Rollbacks: 0
- Skipped Variants: 0 (no compare-at price)
- Duration: 1 seconds
- Completed: 2025-08-06 19:42:44 UTC
---
## Price Update Operation - 2025-08-06 19:46:44 UTC
**Configuration:**
- Target Tag: summer-sale
- Price Adjustment: 10%
- Started: 2025-08-06 19:46:44 UTC
**Progress:**
-**The Hidden Snowboard** (gid://shopify/Product/8116504920355)
- Variant: gid://shopify/ProductVariant/44236769788195
- Price: $749.99 → $824.99
- Compare At Price: $749.99
- Updated: 2025-08-06 19:46:45 UTC
**Summary:**
- Total Products Processed: 1
- Successful Updates: 1
- Failed Updates: 0
- Duration: 1 seconds
- Completed: 2025-08-06 19:46:45 UTC
---
## Price Rollback Operation - 2025-08-06 19:46:55 UTC
**Configuration:**
- Target Tag: summer-sale
- Operation Mode: rollback
- Started: 2025-08-06 19:46:55 UTC
**Progress:**
-**The Hidden Snowboard** (gid://shopify/Product/8116504920355)
- Variant: gid://shopify/ProductVariant/44236769788195
- Price: $824.99 → $749.99
- Compare At Price: $749.99
- Updated: 2025-08-06 19:46:55 UTC
**Rollback Summary:**
- Total Products Processed: 1
- Total Variants Processed: 1
- Eligible Variants: 1
- Successful Rollbacks: 1
- Failed Rollbacks: 0
- Skipped Variants: 0 (no compare-at price)
- Duration: 1 seconds
- Completed: 2025-08-06 19:46:55 UTC
---
## Price Update Operation - 2025-08-06 19:47:08 UTC
**Configuration:**
- Target Tag: summer-sale
- Price Adjustment: 10%
- Started: 2025-08-06 19:47:08 UTC
**Progress:**
-**The Hidden Snowboard** (gid://shopify/Product/8116504920355)
- Variant: gid://shopify/ProductVariant/44236769788195
- Price: $749.99 → $824.99
- Compare At Price: $749.99
- Updated: 2025-08-06 19:47:09 UTC
**Summary:**
- Total Products Processed: 1
- Successful Updates: 1
- Failed Updates: 0
- Duration: 1 seconds
- Completed: 2025-08-06 19:47:09 UTC
---
## Price Update Operation - 2025-08-06 19:48:30 UTC
**Configuration:**
- Target Tag: nonexistent-tag
- Price Adjustment: 10%
- Started: 2025-08-06 19:48:30 UTC
**Progress:**
**Summary:**
- Total Products Processed: 0
- Successful Updates: 0
- Failed Updates: 0
- Duration: 0 seconds
- Completed: 2025-08-06 19:48:30 UTC
---
## Price Rollback Operation - 2025-08-06 19:49:01 UTC
**Configuration:**
- Target Tag: nonexistent-tag
- Operation Mode: rollback
- Started: 2025-08-06 19:49:01 UTC
**Progress:**
**Rollback Summary:**
- Total Products Processed: 0
- Total Variants Processed: 0
- Eligible Variants: 0
- Successful Rollbacks: 0
- Failed Rollbacks: 0
- Skipped Variants: 0 (no compare-at price)
- Duration: 0 seconds
- Completed: 2025-08-06 19:49:02 UTC
---

View File

@@ -49,9 +49,24 @@ TARGET_TAG=sale
# Price adjustment percentage (positive for increase, negative for decrease)
# Examples: 10 (increase by 10%), -15 (decrease by 15%), 5.5 (increase by 5.5%)
# Note: Only used in "update" mode, ignored in "rollback" mode
PRICE_ADJUSTMENT_PERCENTAGE=10
# Operation mode - determines whether to update prices or rollback to compare-at prices
# Options: "update" (default) or "rollback"
# When not specified, defaults to "update" for backward compatibility
OPERATION_MODE=update
```
### Operation Mode Configuration
The `OPERATION_MODE` environment variable controls the application behavior:
- **`update` (default)**: Performs price adjustments using `PRICE_ADJUSTMENT_PERCENTAGE`
- **`rollback`**: Sets prices to compare-at price values and removes compare-at prices
When `OPERATION_MODE` is not specified, the application defaults to `update` mode for backward compatibility.
### Getting Your Shopify Credentials
#### For Private Apps (Recommended):
@@ -84,6 +99,36 @@ or
node src/index.js
```
### Operation Modes
The application supports two operation modes:
#### Update Mode (Default)
Adjusts product prices by a percentage:
```bash
npm run update
```
This performs the standard price adjustment functionality using the `PRICE_ADJUSTMENT_PERCENTAGE` setting.
#### Rollback Mode
Reverts prices by setting the main price to the compare-at price and removing the compare-at price:
```bash
npm run rollback
```
This is useful for reverting promotional pricing back to original prices. Products without compare-at prices will be skipped.
**Operation Mode Indicators:**
- The console output clearly displays which operation mode is active
- Progress.md logs distinguish between "Price Update Operation" and "Price Rollback Operation"
- Configuration summary shows the operation mode being used
### Debug Mode
Before running the main script, you can use the debug mode to see what tags exist in your store and verify your target tag:
@@ -278,7 +323,9 @@ shopify-price-updater/
## Available Scripts
- `npm start` - Run the main price update script
- `npm start` - Run the price updater (defaults to update mode for backward compatibility)
- `npm run update` - Run the price update script (explicitly set to update mode)
- `npm run rollback` - Run the price rollback script (set prices to compare-at prices)
- `npm run debug-tags` - Analyze all product tags in your store
- `npm test` - Run the test suite (if implemented)

View File

@@ -5,6 +5,8 @@
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"update": "set OPERATION_MODE=update && node src/index.js",
"rollback": "set OPERATION_MODE=rollback && node src/index.js",
"debug-tags": "node debug-tags.js",
"test": "jest"
},

View File

@@ -14,6 +14,7 @@ function loadEnvironmentConfig() {
accessToken: process.env.SHOPIFY_ACCESS_TOKEN,
targetTag: process.env.TARGET_TAG,
priceAdjustmentPercentage: process.env.PRICE_ADJUSTMENT_PERCENTAGE,
operationMode: process.env.OPERATION_MODE || "update", // Default to "update" for backward compatibility
};
// Validate required environment variables
@@ -85,12 +86,21 @@ function loadEnvironmentConfig() {
);
}
// Validate operation mode
const validOperationModes = ["update", "rollback"];
if (!validOperationModes.includes(config.operationMode)) {
throw new Error(
`Invalid OPERATION_MODE: "${config.operationMode}". Must be either "update" or "rollback".`
);
}
// Return validated configuration
return {
shopDomain: config.shopDomain.trim(),
accessToken: config.accessToken.trim(),
targetTag: trimmedTag,
priceAdjustmentPercentage: percentage,
operationMode: config.operationMode,
};
}

View File

@@ -31,8 +31,12 @@ class ShopifyPriceUpdater {
// Load and validate configuration
this.config = getConfig();
// Log operation start with configuration (Requirement 3.1)
// Log operation start with configuration based on operation mode (Requirements 3.1, 8.4, 9.3)
if (this.config.operationMode === "rollback") {
await this.logger.logRollbackStart(this.config);
} else {
await this.logger.logOperationStart(this.config);
}
return true;
} catch (error) {
@@ -210,7 +214,191 @@ class ShopifyPriceUpdater {
}
/**
* Run the complete price update workflow
* Fetch products by tag and validate them for rollback operations
* @returns {Promise<Array|null>} Array of rollback-eligible products or null if failed
*/
async fetchAndValidateProductsForRollback() {
try {
// Fetch products by tag
await this.logger.info(
`Fetching products with tag: ${this.config.targetTag}`
);
const products = await this.productService.fetchProductsByTag(
this.config.targetTag
);
// Log product count (Requirement 3.2)
await this.logger.logProductCount(products.length);
if (products.length === 0) {
await this.logger.info(
"No products found with the specified tag. Operation completed."
);
return [];
}
// Validate products for rollback operations
const eligibleProducts =
await this.productService.validateProductsForRollback(products);
// Display summary statistics for rollback
const summary = this.productService.getProductSummary(eligibleProducts);
await this.logger.info(`Rollback Product Summary:`);
await this.logger.info(` - Total Products: ${summary.totalProducts}`);
await this.logger.info(` - Total Variants: ${summary.totalVariants}`);
await this.logger.info(
` - Price Range: ${summary.priceRange.min} - ${summary.priceRange.max}`
);
return eligibleProducts;
} catch (error) {
await this.logger.error(
`Failed to fetch products for rollback: ${error.message}`
);
return null;
}
}
/**
* Execute rollback operations for all products
* @param {Array} products - Array of products to rollback
* @returns {Promise<Object|null>} Rollback results or null if failed
*/
async rollbackPrices(products) {
try {
if (products.length === 0) {
return {
totalProducts: 0,
totalVariants: 0,
eligibleVariants: 0,
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
errors: [],
};
}
await this.logger.info(`Starting price rollback operations`);
// Execute rollback operations
const results = await this.productService.rollbackProductPrices(products);
return results;
} catch (error) {
await this.logger.error(`Price rollback failed: ${error.message}`);
return null;
}
}
/**
* Display operation mode header with clear indication of active mode (Requirements 9.3, 8.4)
* @returns {Promise<void>}
*/
async displayOperationModeHeader() {
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
blue: "\x1b[34m",
green: "\x1b[32m",
yellow: "\x1b[33m",
};
console.log("\n" + "=".repeat(60));
if (this.config.operationMode === "rollback") {
console.log(
`${colors.bright}${colors.yellow}🔄 SHOPIFY PRICE ROLLBACK MODE${colors.reset}`
);
console.log(
`${colors.yellow}Reverting prices from compare-at to main price${colors.reset}`
);
} else {
console.log(
`${colors.bright}${colors.green}📈 SHOPIFY PRICE UPDATE MODE${colors.reset}`
);
console.log(
`${colors.green}Adjusting prices by ${this.config.priceAdjustmentPercentage}%${colors.reset}`
);
}
console.log("=".repeat(60) + "\n");
// Log operation mode to progress file as well
await this.logger.info(
`Operation Mode: ${this.config.operationMode.toUpperCase()}`
);
}
/**
* Display rollback-specific summary and determine exit status
* @param {Object} results - Rollback results
* @returns {number} Exit status code
*/
async displayRollbackSummary(results) {
// Prepare comprehensive summary for rollback logging (Requirement 3.4, 8.4)
const summary = {
totalProducts: results.totalProducts,
totalVariants: results.totalVariants,
eligibleVariants: results.eligibleVariants,
successfulRollbacks: results.successfulRollbacks,
failedRollbacks: results.failedRollbacks,
skippedVariants: results.skippedVariants,
startTime: this.startTime,
errors: results.errors || [],
};
// Log rollback completion summary
await this.logger.logRollbackSummary(summary);
// Perform error analysis if there were failures (Requirement 3.5)
if (results.errors && results.errors.length > 0) {
await this.logger.logErrorAnalysis(results.errors, summary);
}
// Determine exit status with enhanced logic for rollback (Requirement 4.5)
const successRate =
summary.eligibleVariants > 0
? (summary.successfulRollbacks / summary.eligibleVariants) * 100
: 0;
if (results.failedRollbacks === 0) {
await this.logger.info(
"🎉 All rollback operations completed successfully!"
);
return 0; // Success
} else if (results.successfulRollbacks > 0) {
if (successRate >= 90) {
await this.logger.info(
`✅ Rollback completed with high success rate (${successRate.toFixed(
1
)}%). Minor issues encountered.`
);
return 0; // High success rate, treat as success
} else if (successRate >= 50) {
await this.logger.warning(
`⚠️ Rollback completed with moderate success rate (${successRate.toFixed(
1
)}%). Review errors above.`
);
return 1; // Partial failure
} else {
await this.logger.error(
`❌ Rollback completed with low success rate (${successRate.toFixed(
1
)}%). Significant issues detected.`
);
return 2; // Poor success rate
}
} else {
await this.logger.error(
"❌ All rollback operations failed. Please check your configuration and try again."
);
return 2; // Complete failure
}
}
/**
* Run the complete application workflow with dual operation mode support
* @returns {Promise<number>} Exit status code
*/
async run() {
@@ -230,13 +418,37 @@ class ShopifyPriceUpdater {
return await this.handleCriticalFailure("API connection failed", 1);
}
// Fetch and validate products with enhanced error handling
// Display operation mode indication in console output (Requirements 9.3, 8.4)
await this.displayOperationModeHeader();
// Operation mode detection logic - route to appropriate workflow (Requirements 8.1, 9.1, 9.2)
if (this.config.operationMode === "rollback") {
// Rollback workflow
const products = await this.safeFetchAndValidateProductsForRollback();
if (products === null) {
return await this.handleCriticalFailure(
"Product fetching for rollback failed",
1
);
}
operationResults = await this.safeRollbackPrices(products);
if (operationResults === null) {
return await this.handleCriticalFailure(
"Price rollback process failed",
1
);
}
// Display rollback-specific summary and determine exit code
return await this.displayRollbackSummary(operationResults);
} else {
// Default update workflow (Requirements 9.4, 9.5 - backward compatibility)
const products = await this.safeFetchAndValidateProducts();
if (products === null) {
return await this.handleCriticalFailure("Product fetching failed", 1);
}
// Update prices with enhanced error handling
operationResults = await this.safeUpdatePrices(products);
if (operationResults === null) {
return await this.handleCriticalFailure(
@@ -247,6 +459,7 @@ class ShopifyPriceUpdater {
// Display summary and determine exit code
return await this.displaySummaryAndGetExitCode(operationResults);
}
} catch (error) {
// Handle any unexpected errors with comprehensive logging (Requirement 4.5)
await this.handleUnexpectedError(error, operationResults);
@@ -345,16 +558,103 @@ class ShopifyPriceUpdater {
}
/**
* Handle critical failures with proper logging
* Safe wrapper for product fetching for rollback with enhanced error handling
* @returns {Promise<Array|null>} Products array or null if failed
*/
async safeFetchAndValidateProductsForRollback() {
try {
return await this.fetchAndValidateProductsForRollback();
} catch (error) {
await this.logger.error(
`Product fetching for rollback error: ${error.message}`
);
if (error.errorHistory) {
await this.logger.error(
`Fetch attempts made: ${error.totalAttempts || "Unknown"}`
);
}
return null;
}
}
/**
* Safe wrapper for rollback operations with enhanced error handling
* @param {Array} products - Products to rollback
* @returns {Promise<Object|null>} Rollback results or null if failed
*/
async safeRollbackPrices(products) {
try {
return await this.rollbackPrices(products);
} catch (error) {
await this.logger.error(`Price rollback error: ${error.message}`);
if (error.errorHistory) {
await this.logger.error(
`Rollback attempts made: ${error.totalAttempts || "Unknown"}`
);
}
// Return partial results if available
return {
totalProducts: products.length,
totalVariants: products.reduce(
(sum, p) => sum + (p.variants?.length || 0),
0
),
eligibleVariants: products.reduce(
(sum, p) => sum + (p.variants?.length || 0),
0
),
successfulRollbacks: 0,
failedRollbacks: products.reduce(
(sum, p) => sum + (p.variants?.length || 0),
0
),
skippedVariants: 0,
errors: [
{
productTitle: "System Error",
productId: "N/A",
errorMessage: error.message,
},
],
};
}
}
/**
* Handle critical failures with proper logging for both operation modes (Requirements 9.2, 9.3)
* @param {string} message - Failure message
* @param {number} exitCode - Exit code to return
* @returns {Promise<number>} Exit code
*/
async handleCriticalFailure(message, exitCode) {
await this.logger.error(`Critical failure: ${message}`);
await this.logger.error(
`Critical failure in ${
this.config?.operationMode || "unknown"
} mode: ${message}`
);
// Ensure progress logging continues even for critical failures
// Use appropriate summary format based on operation mode
try {
if (this.config?.operationMode === "rollback") {
const summary = {
totalProducts: 0,
totalVariants: 0,
eligibleVariants: 0,
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
startTime: this.startTime,
errors: [
{
productTitle: "Critical System Error",
productId: "N/A",
errorMessage: message,
},
],
};
await this.logger.logRollbackSummary(summary);
} else {
const summary = {
totalProducts: 0,
totalVariants: 0,
@@ -370,6 +670,7 @@ class ShopifyPriceUpdater {
],
};
await this.logger.logCompletionSummary(summary);
}
} catch (loggingError) {
console.error(
"Failed to log critical failure summary:",
@@ -381,13 +682,17 @@ class ShopifyPriceUpdater {
}
/**
* Handle unexpected errors with comprehensive logging
* Handle unexpected errors with comprehensive logging for both operation modes (Requirements 9.2, 9.3)
* @param {Error} error - The unexpected error
* @param {Object} operationResults - Partial results if available
* @returns {Promise<void>}
*/
async handleUnexpectedError(error, operationResults) {
await this.logger.error(`Unexpected error occurred: ${error.message}`);
await this.logger.error(
`Unexpected error occurred in ${
this.config?.operationMode || "unknown"
} mode: ${error.message}`
);
// Log error details
if (error.stack) {
@@ -402,7 +707,27 @@ class ShopifyPriceUpdater {
}
// Ensure progress logging continues even for unexpected errors
// Use appropriate summary format based on operation mode
try {
if (this.config?.operationMode === "rollback") {
const summary = {
totalProducts: operationResults?.totalProducts || 0,
totalVariants: operationResults?.totalVariants || 0,
eligibleVariants: operationResults?.eligibleVariants || 0,
successfulRollbacks: operationResults?.successfulRollbacks || 0,
failedRollbacks: operationResults?.failedRollbacks || 0,
skippedVariants: operationResults?.skippedVariants || 0,
startTime: this.startTime,
errors: operationResults?.errors || [
{
productTitle: "Unexpected System Error",
productId: "N/A",
errorMessage: error.message,
},
],
};
await this.logger.logRollbackSummary(summary);
} else {
const summary = {
totalProducts: operationResults?.totalProducts || 0,
totalVariants: operationResults?.totalVariants || 0,
@@ -418,6 +743,7 @@ class ShopifyPriceUpdater {
],
};
await this.logger.logCompletionSummary(summary);
}
} catch (loggingError) {
console.error(
"Failed to log unexpected error summary:",

View File

@@ -1,5 +1,10 @@
const ShopifyService = require("./shopify");
const { calculateNewPrice, preparePriceUpdate } = require("../utils/price");
const {
calculateNewPrice,
preparePriceUpdate,
validateRollbackEligibility,
prepareRollbackUpdate,
} = require("../utils/price");
const Logger = require("../utils/logger");
/**
@@ -251,6 +256,452 @@ class ProductService {
return validProducts;
}
/**
* Validate that products have the required data for rollback operations
* Filters products to only include those with variants that have valid compare-at prices
* @param {Array} products - Array of products to validate for rollback
* @returns {Promise<Array>} Array of products eligible for rollback
*/
async validateProductsForRollback(products) {
const eligibleProducts = [];
let skippedProductCount = 0;
let totalVariantsProcessed = 0;
let eligibleVariantsCount = 0;
await this.logger.info(
`Starting rollback validation for ${products.length} products`
);
for (const product of products) {
// Check if product has variants
if (!product.variants || product.variants.length === 0) {
await this.logger.warning(
`Skipping product "${product.title}" for rollback - no variants found`
);
skippedProductCount++;
continue;
}
// Check each variant for rollback eligibility
const eligibleVariants = [];
for (const variant of product.variants) {
totalVariantsProcessed++;
const eligibilityResult = validateRollbackEligibility(variant);
if (eligibilityResult.isEligible) {
eligibleVariants.push(variant);
eligibleVariantsCount++;
} else {
await this.logger.warning(
`Skipping variant "${variant.title}" in product "${product.title}" for rollback - ${eligibilityResult.reason}`
);
}
}
// Only include products that have at least one eligible variant
if (eligibleVariants.length === 0) {
await this.logger.warning(
`Skipping product "${product.title}" for rollback - no variants with valid compare-at prices`
);
skippedProductCount++;
continue;
}
// Add product with only eligible variants
eligibleProducts.push({
...product,
variants: eligibleVariants,
});
}
// Log validation summary
if (skippedProductCount > 0) {
await this.logger.warning(
`Skipped ${skippedProductCount} products during rollback validation`
);
}
await this.logger.info(
`Rollback validation completed: ${eligibleProducts.length} products eligible (${eligibleVariantsCount}/${totalVariantsProcessed} variants eligible)`
);
return eligibleProducts;
}
/**
* Update a single product variant price for rollback operation
* Sets the main price to the compare-at price and removes the compare-at price
* @param {Object} variant - Variant to rollback
* @param {string} variant.id - Variant ID
* @param {number} variant.price - Current price
* @param {number} variant.compareAtPrice - Compare-at price to use as new price
* @param {string} productId - Product ID that contains this variant
* @returns {Promise<Object>} Rollback result
*/
async rollbackVariantPrice(variant, productId) {
try {
// Validate rollback eligibility before attempting operation (Requirement 4.1)
const eligibilityResult = validateRollbackEligibility(variant);
if (!eligibilityResult.isEligible) {
return {
success: false,
error: `Rollback not eligible: ${eligibilityResult.reason}`,
rollbackDetails: {
oldPrice: variant.price,
compareAtPrice: variant.compareAtPrice,
newPrice: null,
},
errorType: "validation",
retryable: false,
};
}
// Prepare rollback update using utility function
const rollbackUpdate = prepareRollbackUpdate(variant);
const variables = {
productId: productId,
variants: [
{
id: variant.id,
price: rollbackUpdate.newPrice.toString(), // Shopify expects price as string
compareAtPrice: rollbackUpdate.compareAtPrice, // null to remove compare-at price
},
],
};
// Use existing retry logic for rollback API operations (Requirement 4.2)
const response = await this.shopifyService.executeWithRetry(
() =>
this.shopifyService.executeMutation(
this.getProductVariantUpdateMutation(),
variables
),
this.logger
);
// Check for user errors in the response
if (
response.productVariantsBulkUpdate.userErrors &&
response.productVariantsBulkUpdate.userErrors.length > 0
) {
const errors = response.productVariantsBulkUpdate.userErrors
.map((error) => `${error.field}: ${error.message}`)
.join(", ");
// Categorize Shopify API errors for better error analysis (Requirement 4.5)
const errorType = this.categorizeShopifyError(errors);
throw new Error(`Shopify API errors: ${errors}`);
}
return {
success: true,
updatedVariant: response.productVariantsBulkUpdate.productVariants[0],
rollbackDetails: {
oldPrice: variant.price,
compareAtPrice: variant.compareAtPrice,
newPrice: rollbackUpdate.newPrice,
},
};
} catch (error) {
// Enhanced error handling with categorization (Requirements 4.3, 4.4, 4.5)
const errorInfo = this.analyzeRollbackError(error, variant);
return {
success: false,
error: error.message,
rollbackDetails: {
oldPrice: variant.price,
compareAtPrice: variant.compareAtPrice,
newPrice: null,
},
errorType: errorInfo.type,
retryable: errorInfo.retryable,
errorHistory: error.errorHistory || null,
};
}
}
/**
* Process a single product for rollback operations
* @param {Object} product - Product to process for rollback
* @param {Object} results - Results object to update
* @returns {Promise<void>}
*/
async processProductForRollback(product, results) {
for (const variant of product.variants) {
results.totalVariants++;
try {
// Perform rollback operation on the variant with enhanced error handling
const rollbackResult = await this.rollbackVariantPrice(
variant,
product.id
);
if (rollbackResult.success) {
results.successfulRollbacks++;
// Log successful rollback using rollback-specific logging method
await this.logger.logRollbackUpdate({
productId: product.id,
productTitle: product.title,
variantId: variant.id,
oldPrice: rollbackResult.rollbackDetails.oldPrice,
newPrice: rollbackResult.rollbackDetails.newPrice,
compareAtPrice: rollbackResult.rollbackDetails.compareAtPrice,
});
} else {
// Handle different types of rollback failures (Requirements 4.1, 4.3, 4.4)
if (rollbackResult.errorType === "validation") {
// Skip variants without compare-at prices gracefully (Requirement 4.1)
results.skippedVariants++;
await this.logger.warning(
`Skipped variant "${variant.title || variant.id}" in product "${
product.title
}": ${rollbackResult.error}`
);
} else {
// Handle API and other errors (Requirements 4.2, 4.3, 4.4)
results.failedRollbacks++;
// Enhanced error entry with additional context (Requirement 4.5)
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: variant.id,
errorMessage: rollbackResult.error,
errorType: rollbackResult.errorType,
retryable: rollbackResult.retryable,
errorHistory: rollbackResult.errorHistory,
};
results.errors.push(errorEntry);
// Log rollback-specific error with enhanced details
await this.logger.error(
`Rollback failed for variant "${
variant.title || variant.id
}" in product "${product.title}": ${rollbackResult.error}`
);
// Log to progress file as well
await this.logger.logProductError(errorEntry);
}
}
} catch (error) {
// Handle unexpected errors that bypass the rollbackVariantPrice error handling (Requirement 4.4)
results.failedRollbacks++;
// Analyze unexpected error for better categorization
const errorInfo = this.analyzeRollbackError(error, variant);
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: variant.id,
errorMessage: `Unexpected rollback error: ${error.message}`,
errorType: errorInfo.type,
retryable: errorInfo.retryable,
errorHistory: error.errorHistory || null,
};
results.errors.push(errorEntry);
await this.logger.error(
`Unexpected rollback error for variant "${
variant.title || variant.id
}" in product "${product.title}": ${error.message}`
);
await this.logger.logProductError(errorEntry);
}
}
}
/**
* Rollback prices for all variants in a batch of products
* Sets main prices to compare-at prices and removes compare-at prices
* @param {Array} products - Array of products to rollback
* @returns {Promise<Object>} Batch rollback results
*/
async rollbackProductPrices(products) {
await this.logger.info(
`Starting price rollback for ${products.length} products`
);
const results = {
totalProducts: products.length,
totalVariants: 0,
eligibleVariants: products.reduce(
(sum, product) => sum + product.variants.length,
0
),
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
errors: [],
};
let processedProducts = 0;
let consecutiveErrors = 0;
const maxConsecutiveErrors = 5; // Stop processing if too many consecutive errors
// Process products in batches to manage rate limits with enhanced error handling
for (let i = 0; i < products.length; i += this.batchSize) {
const batch = products.slice(i, i + this.batchSize);
const batchNumber = Math.floor(i / this.batchSize) + 1;
const totalBatches = Math.ceil(products.length / this.batchSize);
await this.logger.info(
`Processing rollback batch ${batchNumber} of ${totalBatches} (${batch.length} products)`
);
let batchErrors = 0;
const batchStartTime = Date.now();
// Process each product in the batch with error recovery (Requirements 4.3, 4.4)
for (const product of batch) {
try {
const variantsBefore = results.totalVariants;
await this.processProductForRollback(product, results);
// Check if this product had any successful operations
const variantsProcessed = results.totalVariants - variantsBefore;
const productErrors = results.errors.filter(
(e) => e.productId === product.id
).length;
if (productErrors === 0) {
consecutiveErrors = 0; // Reset consecutive error counter on success
} else if (productErrors === variantsProcessed) {
// All variants in this product failed
consecutiveErrors++;
batchErrors++;
}
processedProducts++;
// Log progress for large batches
if (processedProducts % 10 === 0) {
await this.logger.info(
`Progress: ${processedProducts}/${products.length} products processed`
);
}
} catch (error) {
// Handle product-level errors that bypass processProductForRollback (Requirement 4.4)
consecutiveErrors++;
batchErrors++;
await this.logger.error(
`Failed to process product "${product.title}" (${product.id}): ${error.message}`
);
// Add product-level error to results
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: "N/A",
errorMessage: `Product processing failed: ${error.message}`,
errorType: "product_processing_error",
retryable: false,
};
results.errors.push(errorEntry);
await this.logger.logProductError(errorEntry);
}
// Check for too many consecutive errors (Requirement 4.4)
if (consecutiveErrors >= maxConsecutiveErrors) {
await this.logger.error(
`Stopping rollback operation due to ${maxConsecutiveErrors} consecutive errors. This may indicate a systemic issue.`
);
// Add summary error for remaining products
const remainingProducts = products.length - processedProducts;
if (remainingProducts > 0) {
const systemErrorEntry = {
productId: "SYSTEM",
productTitle: `${remainingProducts} remaining products`,
variantId: "N/A",
errorMessage: `Processing stopped due to consecutive errors (${maxConsecutiveErrors} in a row)`,
errorType: "system_error",
retryable: true,
};
results.errors.push(systemErrorEntry);
}
break; // Exit product loop
}
}
// Exit batch loop if we hit consecutive error limit
if (consecutiveErrors >= maxConsecutiveErrors) {
break;
}
// Log batch completion with error summary
const batchDuration = Math.round((Date.now() - batchStartTime) / 1000);
if (batchErrors > 0) {
await this.logger.warning(
`Batch ${batchNumber} completed with ${batchErrors} product errors in ${batchDuration}s`
);
} else {
await this.logger.info(
`Batch ${batchNumber} completed successfully in ${batchDuration}s`
);
}
// Add adaptive delay between batches based on error rate (Requirement 4.2)
if (i + this.batchSize < products.length) {
let delay = 500; // Base delay
// Increase delay if we're seeing errors (rate limiting or server issues)
if (batchErrors > 0) {
delay = Math.min(delay * (1 + batchErrors), 5000); // Cap at 5 seconds
await this.logger.info(
`Increasing delay to ${delay}ms due to batch errors`
);
}
await this.delay(delay);
}
}
// Enhanced completion logging with error analysis (Requirement 4.5)
const successRate =
results.eligibleVariants > 0
? (
(results.successfulRollbacks / results.eligibleVariants) *
100
).toFixed(1)
: 0;
await this.logger.info(
`Price rollback completed. Success: ${results.successfulRollbacks}, Failed: ${results.failedRollbacks}, Skipped: ${results.skippedVariants}, Success Rate: ${successRate}%`
);
// Log error summary if there were failures
if (results.errors.length > 0) {
const errorsByType = {};
results.errors.forEach((error) => {
const type = error.errorType || "unknown";
errorsByType[type] = (errorsByType[type] || 0) + 1;
});
await this.logger.warning(
`Error breakdown: ${Object.entries(errorsByType)
.map(([type, count]) => `${type}: ${count}`)
.join(", ")}`
);
}
return results;
}
/**
* Get summary statistics for fetched products
* @param {Array} products - Array of products
@@ -558,6 +1009,152 @@ class ProductService {
}
}
/**
* Categorize Shopify API errors for better error analysis (Requirement 4.5)
* @param {string} errorMessage - Shopify API error message
* @returns {string} Error category
*/
categorizeShopifyError(errorMessage) {
const message = errorMessage.toLowerCase();
if (
message.includes("price") &&
(message.includes("invalid") || message.includes("must be"))
) {
return "price_validation";
}
if (message.includes("variant") && message.includes("not found")) {
return "variant_not_found";
}
if (message.includes("product") && message.includes("not found")) {
return "product_not_found";
}
if (message.includes("permission") || message.includes("access")) {
return "permission_denied";
}
if (message.includes("rate limit") || message.includes("throttled")) {
return "rate_limit";
}
return "shopify_api_error";
}
/**
* Analyze rollback errors for enhanced error handling (Requirements 4.3, 4.4, 4.5)
* @param {Error} error - Error to analyze
* @param {Object} variant - Variant that caused the error
* @returns {Object} Error analysis result
*/
analyzeRollbackError(error, variant) {
const message = error.message.toLowerCase();
// Network and connection errors (retryable)
if (
message.includes("network") ||
message.includes("connection") ||
message.includes("timeout") ||
message.includes("econnreset")
) {
return {
type: "network_error",
retryable: true,
category: "Network Issues",
};
}
// Rate limiting errors (retryable)
if (
message.includes("rate limit") ||
message.includes("429") ||
message.includes("throttled")
) {
return {
type: "rate_limit",
retryable: true,
category: "Rate Limiting",
};
}
// Server errors (retryable)
if (
message.includes("500") ||
message.includes("502") ||
message.includes("503") ||
message.includes("server error")
) {
return {
type: "server_error",
retryable: true,
category: "Server Errors",
};
}
// Authentication errors (not retryable)
if (
message.includes("unauthorized") ||
message.includes("401") ||
message.includes("authentication")
) {
return {
type: "authentication_error",
retryable: false,
category: "Authentication",
};
}
// Permission errors (not retryable)
if (
message.includes("forbidden") ||
message.includes("403") ||
message.includes("permission")
) {
return {
type: "permission_error",
retryable: false,
category: "Permissions",
};
}
// Data validation errors (not retryable)
if (
message.includes("invalid") ||
message.includes("validation") ||
message.includes("price") ||
message.includes("compare-at")
) {
return {
type: "validation_error",
retryable: false,
category: "Data Validation",
};
}
// Resource not found errors (not retryable)
if (message.includes("not found") || message.includes("404")) {
return {
type: "not_found_error",
retryable: false,
category: "Resource Not Found",
};
}
// Shopify API specific errors
if (message.includes("shopify") && message.includes("api")) {
return {
type: "shopify_api_error",
retryable: false,
category: "Shopify API",
};
}
// Unknown errors (potentially retryable)
return {
type: "unknown_error",
retryable: true,
category: "Other",
};
}
/**
* Utility function to add delay between operations
* @param {number} ms - Milliseconds to delay

View File

@@ -35,6 +35,28 @@ class ProgressService {
- Price Adjustment: ${config.priceAdjustmentPercentage}%
- Started: ${timestamp}
**Progress:**
`;
await this.appendToProgressFile(content);
}
/**
* Logs the start of a price rollback operation (Requirements 7.1, 8.3)
* @param {Object} config - Configuration object with operation details
* @param {string} config.targetTag - The tag being targeted
* @returns {Promise<void>}
*/
async logRollbackStart(config) {
const timestamp = this.formatTimestamp();
const content = `
## Price Rollback Operation - ${timestamp}
**Configuration:**
- Target Tag: ${config.targetTag}
- Operation Mode: rollback
- Started: ${timestamp}
**Progress:**
`;
@@ -66,6 +88,28 @@ class ProgressService {
await this.appendToProgressFile(content);
}
/**
* Logs a successful product rollback (Requirements 7.2, 8.3)
* @param {Object} entry - Rollback progress entry object
* @param {string} entry.productId - Shopify product ID
* @param {string} entry.productTitle - Product title
* @param {string} entry.variantId - Variant ID
* @param {number} entry.oldPrice - Original price before rollback
* @param {number} entry.compareAtPrice - Compare-at price being used as new price
* @param {number} entry.newPrice - New price (same as compare-at price)
* @returns {Promise<void>}
*/
async logRollbackUpdate(entry) {
const timestamp = this.formatTimestamp();
const content = `- 🔄 **${entry.productTitle}** (${entry.productId})
- Variant: ${entry.variantId}
- Price: $${entry.oldPrice}$${entry.newPrice} (from Compare At: $${entry.compareAtPrice})
- Rolled back: ${timestamp}
`;
await this.appendToProgressFile(content);
}
/**
* Logs an error that occurred during product processing
* @param {Object} entry - Progress entry object with error details
@@ -111,6 +155,42 @@ class ProgressService {
---
`;
await this.appendToProgressFile(content);
}
/**
* Logs the completion summary of a rollback operation (Requirements 7.3, 8.3)
* @param {Object} summary - Rollback summary statistics
* @param {number} summary.totalProducts - Total products processed
* @param {number} summary.totalVariants - Total variants processed
* @param {number} summary.eligibleVariants - Variants eligible for rollback
* @param {number} summary.successfulRollbacks - Number of successful rollbacks
* @param {number} summary.failedRollbacks - Number of failed rollbacks
* @param {number} summary.skippedVariants - Variants skipped (no compare-at price)
* @param {Date} summary.startTime - Operation start time
* @returns {Promise<void>}
*/
async logRollbackSummary(summary) {
const timestamp = this.formatTimestamp();
const duration = summary.startTime
? Math.round((new Date() - summary.startTime) / 1000)
: "Unknown";
const content = `
**Rollback Summary:**
- Total Products Processed: ${summary.totalProducts}
- Total Variants Processed: ${summary.totalVariants}
- Eligible Variants: ${summary.eligibleVariants}
- Successful Rollbacks: ${summary.successfulRollbacks}
- Failed Rollbacks: ${summary.failedRollbacks}
- Skipped Variants: ${summary.skippedVariants} (no compare-at price)
- Duration: ${duration} seconds
- Completed: ${timestamp}
---
`;
await this.appendToProgressFile(content);
@@ -191,6 +271,7 @@ ${content}`;
// Categorize errors by type
const errorCategories = {};
const errorDetails = [];
const retryableCount = { retryable: 0, nonRetryable: 0, unknown: 0 };
errors.forEach((error, index) => {
const category = this.categorizeError(
@@ -201,6 +282,15 @@ ${content}`;
}
errorCategories[category]++;
// Track retryable status for rollback analysis
if (error.retryable === true) {
retryableCount.retryable++;
} else if (error.retryable === false) {
retryableCount.nonRetryable++;
} else {
retryableCount.unknown++;
}
errorDetails.push({
index: index + 1,
product: error.productTitle || "Unknown",
@@ -208,6 +298,14 @@ ${content}`;
variantId: error.variantId || "N/A",
error: error.errorMessage || error.error || "Unknown error",
category,
errorType: error.errorType || "unknown",
retryable:
error.retryable !== undefined
? error.retryable
? "Yes"
: "No"
: "Unknown",
hasHistory: error.errorHistory ? "Yes" : "No",
});
});
@@ -222,15 +320,28 @@ ${content}`;
content += `- ${category}: ${count} error${count !== 1 ? "s" : ""}\n`;
});
// Add retryable analysis for rollback operations
if (retryableCount.retryable > 0 || retryableCount.nonRetryable > 0) {
content += `
**Retryability Analysis:**
- Retryable Errors: ${retryableCount.retryable}
- Non-Retryable Errors: ${retryableCount.nonRetryable}
- Unknown Retryability: ${retryableCount.unknown}
`;
}
content += `
**Detailed Error Log:**
`;
// Add detailed error information
// Add detailed error information with enhanced fields
errorDetails.forEach((detail) => {
content += `${detail.index}. **${detail.product}** (${detail.productId})
- Variant: ${detail.variantId}
- Category: ${detail.category}
- Error Type: ${detail.errorType}
- Retryable: ${detail.retryable}
- Has Retry History: ${detail.hasHistory}
- Error: ${detail.error}
`;
});

View File

@@ -95,6 +95,26 @@ class Logger {
await this.progressService.logOperationStart(config);
}
/**
* Logs rollback operation start with configuration details (Requirements 3.1, 7.1, 8.3)
* @param {Object} config - Configuration object
* @returns {Promise<void>}
*/
async logRollbackStart(config) {
await this.info(`Starting price rollback operation with configuration:`);
await this.info(` Target Tag: ${config.targetTag}`);
await this.info(` Operation Mode: rollback`);
await this.info(` Shop Domain: ${config.shopDomain}`);
// Also log to progress file with rollback-specific format
try {
await this.progressService.logRollbackStart(config);
} catch (error) {
// Progress logging should not block main operations
console.warn(`Warning: Failed to log to progress file: ${error.message}`);
}
}
/**
* Logs product count information (Requirement 3.2)
* @param {number} count - Number of matching products found
@@ -128,6 +148,30 @@ class Logger {
await this.progressService.logProductUpdate(entry);
}
/**
* Logs successful rollback operations (Requirements 3.3, 7.2, 8.3)
* @param {Object} entry - Rollback update entry
* @param {string} entry.productTitle - Product title
* @param {string} entry.productId - Product ID
* @param {string} entry.variantId - Variant ID
* @param {number} entry.oldPrice - Original price before rollback
* @param {number} entry.compareAtPrice - Compare-at price being used as new price
* @param {number} entry.newPrice - New price (same as compare-at price)
* @returns {Promise<void>}
*/
async logRollbackUpdate(entry) {
const message = `${this.colors.green}🔄${this.colors.reset} Rolled back "${entry.productTitle}" - Price: ${entry.oldPrice}${entry.newPrice} (from Compare At: ${entry.compareAtPrice})`;
console.log(message);
// Also log to progress file with rollback-specific format
try {
await this.progressService.logRollbackUpdate(entry);
} catch (error) {
// Progress logging should not block main operations
console.warn(`Warning: Failed to log to progress file: ${error.message}`);
}
}
/**
* Logs completion summary (Requirement 3.4)
* @param {Object} summary - Summary statistics
@@ -163,6 +207,59 @@ class Logger {
await this.progressService.logCompletionSummary(summary);
}
/**
* Logs rollback completion summary (Requirements 3.5, 7.3, 8.3)
* @param {Object} summary - Rollback summary statistics
* @param {number} summary.totalProducts - Total products processed
* @param {number} summary.totalVariants - Total variants processed
* @param {number} summary.eligibleVariants - Variants eligible for rollback
* @param {number} summary.successfulRollbacks - Successful rollback operations
* @param {number} summary.failedRollbacks - Failed rollback operations
* @param {number} summary.skippedVariants - Variants skipped (no compare-at price)
* @param {Date} summary.startTime - Operation start time
* @returns {Promise<void>}
*/
async logRollbackSummary(summary) {
await this.info("=".repeat(50));
await this.info("ROLLBACK OPERATION COMPLETE");
await this.info("=".repeat(50));
await this.info(`Total Products Processed: ${summary.totalProducts}`);
await this.info(`Total Variants Processed: ${summary.totalVariants}`);
await this.info(`Eligible Variants: ${summary.eligibleVariants}`);
await this.info(
`Successful Rollbacks: ${this.colors.green}${summary.successfulRollbacks}${this.colors.reset}`
);
if (summary.failedRollbacks > 0) {
await this.info(
`Failed Rollbacks: ${this.colors.red}${summary.failedRollbacks}${this.colors.reset}`
);
} else {
await this.info(`Failed Rollbacks: ${summary.failedRollbacks}`);
}
if (summary.skippedVariants > 0) {
await this.info(
`Skipped Variants: ${this.colors.yellow}${summary.skippedVariants}${this.colors.reset} (no compare-at price)`
);
} else {
await this.info(`Skipped Variants: ${summary.skippedVariants}`);
}
if (summary.startTime) {
const duration = Math.round((new Date() - summary.startTime) / 1000);
await this.info(`Duration: ${duration} seconds`);
}
// Also log to progress file with rollback-specific format
try {
await this.progressService.logRollbackSummary(summary);
} catch (error) {
// Progress logging should not block main operations
console.warn(`Warning: Failed to log to progress file: ${error.message}`);
}
}
/**
* Logs error details and continues processing (Requirement 3.5)
* @param {Object} entry - Error entry
@@ -226,12 +323,18 @@ class Logger {
return;
}
const operationType =
summary.successfulRollbacks !== undefined ? "ROLLBACK" : "UPDATE";
await this.info("=".repeat(50));
await this.info("ERROR ANALYSIS");
await this.info(`${operationType} ERROR ANALYSIS`);
await this.info("=".repeat(50));
// Categorize errors
// Enhanced categorization for rollback operations
const categories = {};
const retryableErrors = [];
const nonRetryableErrors = [];
errors.forEach((error) => {
const category = this.categorizeError(
error.errorMessage || error.error || "Unknown"
@@ -240,6 +343,13 @@ class Logger {
categories[category] = [];
}
categories[category].push(error);
// Track retryable vs non-retryable errors for rollback analysis
if (error.retryable === true) {
retryableErrors.push(error);
} else if (error.retryable === false) {
nonRetryableErrors.push(error);
}
});
// Display category breakdown
@@ -254,9 +364,52 @@ class Logger {
);
});
// Rollback-specific error analysis (Requirements 4.3, 4.5)
if (operationType === "ROLLBACK") {
await this.info("\nRollback Error Analysis:");
if (retryableErrors.length > 0) {
await this.info(
` Retryable Errors: ${retryableErrors.length} (${(
(retryableErrors.length / errors.length) *
100
).toFixed(1)}%)`
);
}
if (nonRetryableErrors.length > 0) {
await this.info(
` Non-Retryable Errors: ${nonRetryableErrors.length} (${(
(nonRetryableErrors.length / errors.length) *
100
).toFixed(1)}%)`
);
}
// Analyze rollback-specific error patterns
const validationErrors = errors.filter(
(e) =>
e.errorType === "validation_error" || e.errorType === "validation"
);
if (validationErrors.length > 0) {
await this.info(
` Products without compare-at prices: ${validationErrors.length}`
);
}
const networkErrors = errors.filter(
(e) => e.errorType === "network_error"
);
if (networkErrors.length > 0) {
await this.info(
` Network-related failures: ${networkErrors.length} (consider retry)`
);
}
}
// Provide recommendations based on error patterns
await this.info("\nRecommendations:");
await this.provideErrorRecommendations(categories, summary);
await this.provideErrorRecommendations(categories, summary, operationType);
// Log to progress file as well
await this.progressService.logErrorAnalysis(errors);
@@ -327,9 +480,14 @@ class Logger {
* Provide recommendations based on error patterns
* @param {Object} categories - Categorized errors
* @param {Object} summary - Operation summary
* @param {string} operationType - Type of operation ('UPDATE' or 'ROLLBACK')
* @returns {Promise<void>}
*/
async provideErrorRecommendations(categories, summary) {
async provideErrorRecommendations(
categories,
summary,
operationType = "UPDATE"
) {
if (categories["Rate Limiting"]) {
await this.info(
" • Consider reducing batch size or adding delays between requests"
@@ -342,6 +500,11 @@ class Logger {
if (categories["Network Issues"]) {
await this.info(" • Check your internet connection stability");
await this.info(" • Consider running the script during off-peak hours");
if (operationType === "ROLLBACK") {
await this.info(
" • Network errors during rollback are retryable - consider re-running"
);
}
}
if (categories["Authentication"]) {
@@ -352,6 +515,17 @@ class Logger {
}
if (categories["Data Validation"]) {
if (operationType === "ROLLBACK") {
await this.info(
" • Products without compare-at prices cannot be rolled back"
);
await this.info(
" • Consider filtering products to only include those with compare-at prices"
);
await this.info(
" • Review which products were updated in the original price adjustment"
);
} else {
await this.info(
" • Review product data for invalid prices or missing information"
);
@@ -359,25 +533,72 @@ class Logger {
" • Consider adding more robust data validation before updates"
);
}
}
if (categories["Server Errors"]) {
await this.info(" • Shopify may be experiencing temporary issues");
await this.info(" • Try running the script again later");
if (operationType === "ROLLBACK") {
await this.info(" • Server errors during rollback are retryable");
}
}
// Success rate analysis
const successRate =
summary.totalVariants > 0
? ((summary.successfulUpdates / summary.totalVariants) * 100).toFixed(1)
// Rollback-specific recommendations (Requirement 4.5)
if (operationType === "ROLLBACK") {
if (categories["Resource Not Found"]) {
await this.info(
" • Some products or variants may have been deleted since the original update"
);
await this.info(
" • Consider checking product existence before rollback operations"
);
}
if (categories["Permissions"]) {
await this.info(
" • Ensure your API credentials have product update permissions"
);
await this.info(
" • Rollback operations require the same permissions as price updates"
);
}
}
// Success rate analysis with operation-specific metrics
let successRate;
if (operationType === "ROLLBACK") {
successRate =
summary.eligibleVariants > 0
? (
(summary.successfulRollbacks / summary.eligibleVariants) *
100
).toFixed(1)
: 0;
} else {
successRate =
summary.totalVariants > 0
? ((summary.successfulUpdates / summary.totalVariants) * 100).toFixed(
1
)
: 0;
}
if (successRate < 50) {
await this.warning(
" • Low success rate detected - consider reviewing configuration"
` • Low success rate detected (${successRate}%) - consider reviewing configuration`
);
if (operationType === "ROLLBACK") {
await this.warning(
" • Many products may not have valid compare-at prices for rollback"
);
}
} else if (successRate < 90) {
await this.info(
" • Moderate success rate - some optimization may be beneficial"
` • Moderate success rate (${successRate}%) - some optimization may be beneficial`
);
} else {
await this.info(
` • Good success rate (${successRate}%) - most operations completed successfully`
);
}
}

View File

@@ -133,6 +133,134 @@ function preparePriceUpdate(originalPrice, percentage) {
};
}
/**
* Validates if a variant is eligible for rollback operation
* @param {Object} variant - The variant object with price and compareAtPrice
* @returns {Object} Object containing isEligible boolean and reason if not eligible
*/
function validateRollbackEligibility(variant) {
// Check if variant object exists
if (!variant || typeof variant !== "object") {
return {
isEligible: false,
reason: "Invalid variant object",
variant: null,
};
}
// Extract price and compareAtPrice from variant
const currentPrice = parseFloat(variant.price);
const compareAtPrice =
variant.compareAtPrice !== null && variant.compareAtPrice !== undefined
? parseFloat(variant.compareAtPrice)
: null;
// Check if current price is valid
if (!isValidPrice(currentPrice)) {
return {
isEligible: false,
reason: "Invalid current price",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price exists
if (compareAtPrice === null || compareAtPrice === undefined) {
return {
isEligible: false,
reason: "No compare-at price available",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price is a valid number first
if (
typeof compareAtPrice !== "number" ||
isNaN(compareAtPrice) ||
!isFinite(compareAtPrice)
) {
return {
isEligible: false,
reason: "Invalid compare-at price",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price is positive (greater than 0)
if (compareAtPrice <= 0) {
return {
isEligible: false,
reason: "Compare-at price must be greater than zero",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price is different from current price
if (Math.abs(currentPrice - compareAtPrice) < 0.01) {
// Use small epsilon for floating point comparison
return {
isEligible: false,
reason: "Compare-at price is the same as current price",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Variant is eligible for rollback
return {
isEligible: true,
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
/**
* Prepares a rollback update object for a variant
* @param {Object} variant - The variant object with price and compareAtPrice
* @returns {Object} Object containing newPrice and compareAtPrice for rollback operation
* @throws {Error} If variant is not eligible for rollback
*/
function prepareRollbackUpdate(variant) {
// First validate if the variant is eligible for rollback
const eligibilityResult = validateRollbackEligibility(variant);
if (!eligibilityResult.isEligible) {
throw new Error(
`Cannot prepare rollback update: ${eligibilityResult.reason}`
);
}
const { currentPrice, compareAtPrice } = eligibilityResult.variant;
// For rollback: new price becomes the compare-at price, compare-at price becomes null
return {
newPrice: compareAtPrice,
compareAtPrice: null,
};
}
module.exports = {
calculateNewPrice,
isValidPrice,
@@ -140,4 +268,6 @@ module.exports = {
calculatePercentageChange,
isValidPercentage,
preparePriceUpdate,
validateRollbackEligibility,
prepareRollbackUpdate,
};

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