diff --git a/.env.example b/.env.example index 448cdd8..f67f03b 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +# Example: 10 = 10% increase, -15 = 15% decrease +# Note: PRICE_ADJUSTMENT_PERCENTAGE is only used in "update" mode \ No newline at end of file diff --git a/.kiro/specs/price-rollback/design.md b/.kiro/specs/price-rollback/design.md new file mode 100644 index 0000000..3f981d9 --- /dev/null +++ b/.kiro/specs/price-rollback/design.md @@ -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 diff --git a/.kiro/specs/price-rollback/requirements.md b/.kiro/specs/price-rollback/requirements.md new file mode 100644 index 0000000..19e64ad --- /dev/null +++ b/.kiro/specs/price-rollback/requirements.md @@ -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 diff --git a/.kiro/specs/price-rollback/tasks.md b/.kiro/specs/price-rollback/tasks.md new file mode 100644 index 0000000..c9ad03c --- /dev/null +++ b/.kiro/specs/price-rollback/tasks.md @@ -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_ diff --git a/Progress.md b/Progress.md index c5cee05..ee1cdc7 100644 --- a/Progress.md +++ b/Progress.md @@ -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 + +--- + diff --git a/README.md b/README.md index 2cc1d46..04dee0b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/package.json b/package.json index 1e4dc73..1990e5a 100644 --- a/package.json +++ b/package.json @@ -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" }, @@ -27,4 +29,4 @@ "engines": { "node": ">=16.0.0" } -} +} \ No newline at end of file diff --git a/src/config/environment.js b/src/config/environment.js index 20d2530..8ff81df 100644 --- a/src/config/environment.js +++ b/src/config/environment.js @@ -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, }; } diff --git a/src/index.js b/src/index.js index 8809b2a..ef09bbe 100644 --- a/src/index.js +++ b/src/index.js @@ -31,8 +31,12 @@ class ShopifyPriceUpdater { // Load and validate configuration this.config = getConfig(); - // Log operation start with configuration (Requirement 3.1) - await this.logger.logOperationStart(this.config); + // 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 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} 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} + */ + 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} Exit status code */ async run() { @@ -230,23 +418,48 @@ class ShopifyPriceUpdater { return await this.handleCriticalFailure("API connection failed", 1); } - // Fetch and validate products with enhanced error handling - const products = await this.safeFetchAndValidateProducts(); - if (products === null) { - return await this.handleCriticalFailure("Product fetching failed", 1); - } + // Display operation mode indication in console output (Requirements 9.3, 8.4) + await this.displayOperationModeHeader(); - // Update prices with enhanced error handling - operationResults = await this.safeUpdatePrices(products); - if (operationResults === null) { - return await this.handleCriticalFailure( - "Price update process failed", - 1 - ); - } + // 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 + ); + } - // Display summary and determine exit code - return await this.displaySummaryAndGetExitCode(operationResults); + 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); + } + + operationResults = await this.safeUpdatePrices(products); + if (operationResults === null) { + return await this.handleCriticalFailure( + "Price update process failed", + 1 + ); + } + + // 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,31 +558,119 @@ class ShopifyPriceUpdater { } /** - * Handle critical failures with proper logging + * Safe wrapper for product fetching for rollback with enhanced error handling + * @returns {Promise} 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} 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} 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 { - const summary = { - totalProducts: 0, - totalVariants: 0, - successfulUpdates: 0, - failedUpdates: 0, - startTime: this.startTime, - errors: [ - { - productTitle: "Critical System Error", - productId: "N/A", - errorMessage: message, - }, - ], - }; - await this.logger.logCompletionSummary(summary); + 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, + successfulUpdates: 0, + failedUpdates: 0, + startTime: this.startTime, + errors: [ + { + productTitle: "Critical System Error", + productId: "N/A", + errorMessage: message, + }, + ], + }; + 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} */ 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,22 +707,43 @@ class ShopifyPriceUpdater { } // Ensure progress logging continues even for unexpected errors + // Use appropriate summary format based on operation mode try { - const summary = { - totalProducts: operationResults?.totalProducts || 0, - totalVariants: operationResults?.totalVariants || 0, - successfulUpdates: operationResults?.successfulUpdates || 0, - failedUpdates: operationResults?.failedUpdates || 0, - startTime: this.startTime, - errors: operationResults?.errors || [ - { - productTitle: "Unexpected System Error", - productId: "N/A", - errorMessage: error.message, - }, - ], - }; - await this.logger.logCompletionSummary(summary); + 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, + successfulUpdates: operationResults?.successfulUpdates || 0, + failedUpdates: operationResults?.failedUpdates || 0, + startTime: this.startTime, + errors: operationResults?.errors || [ + { + productTitle: "Unexpected System Error", + productId: "N/A", + errorMessage: error.message, + }, + ], + }; + await this.logger.logCompletionSummary(summary); + } } catch (loggingError) { console.error( "Failed to log unexpected error summary:", diff --git a/src/services/product.js b/src/services/product.js index 6d05961..1fb6aeb 100644 --- a/src/services/product.js +++ b/src/services/product.js @@ -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 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} 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} + */ + 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} 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 diff --git a/src/services/progress.js b/src/services/progress.js index b378e47..a64ef2b 100644 --- a/src/services/progress.js +++ b/src/services/progress.js @@ -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} + */ + 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} + */ + 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} + */ + 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} `; }); diff --git a/src/utils/logger.js b/src/utils/logger.js index 7160c04..18f8c01 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -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} + */ + 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} + */ + 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} + */ + 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} */ - 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,32 +515,90 @@ class Logger { } if (categories["Data Validation"]) { - await this.info( - " • Review product data for invalid prices or missing information" - ); - await this.info( - " • Consider adding more robust data validation before updates" - ); + 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" + ); + await this.info( + " • 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) - : 0; + // 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` ); } } diff --git a/src/utils/price.js b/src/utils/price.js index f4d15d2..1357863 100644 --- a/src/utils/price.js +++ b/src/utils/price.js @@ -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, }; diff --git a/tests/config/environment.test.js b/tests/config/environment.test.js index 6fe8c40..75e7464 100644 --- a/tests/config/environment.test.js +++ b/tests/config/environment.test.js @@ -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); + }); + }); }); }); diff --git a/tests/index.test.js b/tests/index.test.js new file mode 100644 index 0000000..e03af8d --- /dev/null +++ b/tests/index.test.js @@ -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(); + }); + }); +}); diff --git a/tests/integration/rollback-workflow.test.js b/tests/integration/rollback-workflow.test.js new file mode 100644 index 0000000..c1582c8 --- /dev/null +++ b/tests/integration/rollback-workflow.test.js @@ -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); + }); + }); +}); diff --git a/tests/services/product.test.js b/tests/services/product.test.js index e84010b..d751d62 100644 --- a/tests/services/product.test.js +++ b/tests/services/product.test.js @@ -24,6 +24,7 @@ describe("ProductService Integration Tests", () => { warning: jest.fn(), error: jest.fn(), logProductUpdate: jest.fn(), + logRollbackUpdate: jest.fn(), logProductError: jest.fn(), }; @@ -850,4 +851,1344 @@ describe("ProductService Integration Tests", () => { expect(mockLogger.logProductError).toHaveBeenCalledTimes(1); }); }); + + describe("Rollback Validation", () => { + test("should validate products with compare-at prices for rollback", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With Compare-At Prices", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 19.99, + compareAtPrice: 24.99, + title: "Variant 1", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 29.99, + compareAtPrice: 39.99, + title: "Variant 2", + }, + ], + }, + { + id: "gid://shopify/Product/456", + title: "Another Product", + variants: [ + { + id: "gid://shopify/ProductVariant/101112", + price: 15.99, + compareAtPrice: 19.99, + title: "Single Variant", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(2); + expect(eligibleProducts[0].variants).toHaveLength(2); + expect(eligibleProducts[1].variants).toHaveLength(1); + expect(mockLogger.info).toHaveBeenCalledWith( + "Starting rollback validation for 2 products" + ); + expect(mockLogger.info).toHaveBeenCalledWith( + "Rollback validation completed: 2 products eligible (3/3 variants eligible)" + ); + }); + + test("should skip products without variants for rollback", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product Without Variants", + variants: [], + }, + { + id: "gid://shopify/Product/456", + title: "Product With Variants", + variants: [ + { + id: "gid://shopify/ProductVariant/789", + price: 29.99, + compareAtPrice: 39.99, + title: "Valid Variant", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].title).toBe("Product With Variants"); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping product "Product Without Variants" for rollback - no variants found' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + "Skipped 1 products during rollback validation" + ); + }); + + test("should skip variants without compare-at prices", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With Mixed Variants", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: 39.99, + title: "Valid Variant", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 19.99, + compareAtPrice: null, + title: "No Compare-At Price", + }, + { + id: "gid://shopify/ProductVariant/101112", + price: 24.99, + compareAtPrice: undefined, + title: "Undefined Compare-At Price", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].variants).toHaveLength(1); + expect(eligibleProducts[0].variants[0].title).toBe("Valid Variant"); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "No Compare-At Price" in product "Product With Mixed Variants" for rollback - No compare-at price available' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Undefined Compare-At Price" in product "Product With Mixed Variants" for rollback - No compare-at price available' + ); + }); + + test("should skip variants with invalid compare-at prices", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With Invalid Compare-At Prices", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: 39.99, + title: "Valid Variant", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 19.99, + compareAtPrice: 0, + title: "Zero Compare-At Price", + }, + { + id: "gid://shopify/ProductVariant/101112", + price: 24.99, + compareAtPrice: -5.99, + title: "Negative Compare-At Price", + }, + { + id: "gid://shopify/ProductVariant/131415", + price: 14.99, + compareAtPrice: "invalid", + title: "Invalid Compare-At Price", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].variants).toHaveLength(1); + expect(eligibleProducts[0].variants[0].title).toBe("Valid Variant"); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Zero Compare-At Price" in product "Product With Invalid Compare-At Prices" for rollback - Compare-at price must be greater than zero' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Negative Compare-At Price" in product "Product With Invalid Compare-At Prices" for rollback - Compare-at price must be greater than zero' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Invalid Compare-At Price" in product "Product With Invalid Compare-At Prices" for rollback - Invalid compare-at price' + ); + }); + + test("should skip variants where current price equals compare-at price", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With Same Prices", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: 39.99, + title: "Valid Variant", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 24.99, + compareAtPrice: 24.99, + title: "Same Price Variant", + }, + { + id: "gid://shopify/ProductVariant/101112", + price: 19.995, + compareAtPrice: 19.99, + title: "Nearly Same Price Variant", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].variants).toHaveLength(1); + expect(eligibleProducts[0].variants[0].title).toBe("Valid Variant"); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Same Price Variant" in product "Product With Same Prices" for rollback - Compare-at price is the same as current price' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Nearly Same Price Variant" in product "Product With Same Prices" for rollback - Compare-at price is the same as current price' + ); + }); + + test("should skip variants with invalid current prices", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With Invalid Current Prices", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: 39.99, + title: "Valid Variant", + }, + { + id: "gid://shopify/ProductVariant/789", + price: "invalid", + compareAtPrice: 24.99, + title: "Invalid Current Price", + }, + { + id: "gid://shopify/ProductVariant/101112", + price: -10.0, + compareAtPrice: 19.99, + title: "Negative Current Price", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].variants).toHaveLength(1); + expect(eligibleProducts[0].variants[0].title).toBe("Valid Variant"); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Invalid Current Price" in product "Product With Invalid Current Prices" for rollback - Invalid current price' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Negative Current Price" in product "Product With Invalid Current Prices" for rollback - Invalid current price' + ); + }); + + test("should skip products with no eligible variants", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With No Eligible Variants", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: null, + title: "No Compare-At Price", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 19.99, + compareAtPrice: 19.99, + title: "Same Price", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(0); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping product "Product With No Eligible Variants" for rollback - no variants with valid compare-at prices' + ); + }); + + test("should handle empty products array", async () => { + const products = []; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(0); + expect(mockLogger.info).toHaveBeenCalledWith( + "Starting rollback validation for 0 products" + ); + expect(mockLogger.info).toHaveBeenCalledWith( + "Rollback validation completed: 0 products eligible (0/0 variants eligible)" + ); + }); + }); + + describe("Rollback Variant Price Updates", () => { + test("should rollback variant price successfully", async () => { + const mockResponse = { + productVariantsBulkUpdate: { + productVariants: [ + { + id: "gid://shopify/ProductVariant/123", + price: "75.00", + compareAtPrice: null, + }, + ], + userErrors: [], + }, + }; + + mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse); + + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 50.0, + compareAtPrice: 75.0, + title: "Test Variant", + }; + + const result = await productService.rollbackVariantPrice( + variant, + "gid://shopify/Product/123" + ); + + expect(result.success).toBe(true); + expect(result.rollbackDetails.oldPrice).toBe(50.0); + expect(result.rollbackDetails.compareAtPrice).toBe(75.0); + expect(result.rollbackDetails.newPrice).toBe(75.0); + expect(result.updatedVariant.price).toBe("75.00"); + expect(result.updatedVariant.compareAtPrice).toBe(null); + + expect(mockShopifyService.executeWithRetry).toHaveBeenCalledWith( + expect.any(Function), + mockLogger + ); + }); + + test("should handle rollback validation failure", async () => { + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 50.0, + compareAtPrice: null, // No compare-at price + title: "Invalid Variant", + }; + + const result = await productService.rollbackVariantPrice( + variant, + "gid://shopify/Product/123" + ); + + expect(result.success).toBe(false); + expect(result.error).toBe( + "Rollback not eligible: No compare-at price available" + ); + expect(result.errorType).toBe("validation"); + expect(result.retryable).toBe(false); + expect(result.rollbackDetails.oldPrice).toBe(50.0); + expect(result.rollbackDetails.newPrice).toBe(null); + + // Should not call Shopify API for invalid variants + expect(mockShopifyService.executeWithRetry).not.toHaveBeenCalled(); + }); + + test("should handle Shopify API user errors during rollback", async () => { + const mockResponse = { + productVariantsBulkUpdate: { + productVariants: [], + userErrors: [ + { + field: "price", + message: "Price cannot be null", + }, + ], + }, + }; + + mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse); + + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 50.0, + compareAtPrice: 75.0, + title: "Test Variant", + }; + + const result = await productService.rollbackVariantPrice( + variant, + "gid://shopify/Product/123" + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("Shopify API errors:"); + expect(result.error).toContain("price: Price cannot be null"); + }); + + test("should handle network errors during rollback", async () => { + const networkError = new Error("Network connection failed"); + networkError.errorHistory = [ + { attempt: 1, error: "Timeout", retryable: true }, + { attempt: 2, error: "Connection refused", retryable: true }, + ]; + + mockShopifyService.executeWithRetry.mockRejectedValue(networkError); + + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 50.0, + compareAtPrice: 75.0, + title: "Test Variant", + }; + + const result = await productService.rollbackVariantPrice( + variant, + "gid://shopify/Product/123" + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Network connection failed"); + expect(result.errorHistory).toEqual(networkError.errorHistory); + }); + }); + + describe("Batch Rollback Operations", () => { + test("should rollback multiple products successfully", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product 1", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 20.0, + compareAtPrice: 25.0, + title: "Variant 1", + }, + ], + }, + { + id: "gid://shopify/Product/789", + title: "Product 2", + variants: [ + { + id: "gid://shopify/ProductVariant/101112", + price: 30.0, + compareAtPrice: 40.0, + title: "Variant 2", + }, + { + id: "gid://shopify/ProductVariant/131415", + price: 15.0, + compareAtPrice: 20.0, + title: "Variant 3", + }, + ], + }, + ]; + + // Mock successful responses for all variants + mockShopifyService.executeWithRetry.mockResolvedValue({ + productVariantsBulkUpdate: { + productVariants: [ + { id: "test", price: "25.00", compareAtPrice: null }, + ], + userErrors: [], + }, + }); + + // Mock delay function + jest.spyOn(productService, "delay").mockResolvedValue(); + + const results = await productService.rollbackProductPrices(products); + + expect(results.totalProducts).toBe(2); + expect(results.totalVariants).toBe(3); + expect(results.eligibleVariants).toBe(3); + expect(results.successfulRollbacks).toBe(3); + expect(results.failedRollbacks).toBe(0); + expect(results.skippedVariants).toBe(0); + expect(results.errors).toHaveLength(0); + + expect(mockLogger.logRollbackUpdate).toHaveBeenCalledTimes(3); + expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(3); + }); + + test("should handle mixed success and failure scenarios in rollback", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product 1", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 20.0, + compareAtPrice: 25.0, + title: "Success Variant", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 30.0, + compareAtPrice: 40.0, + title: "Failure Variant", + }, + ], + }, + ]; + + // Mock first call succeeds, second fails + mockShopifyService.executeWithRetry + .mockResolvedValueOnce({ + productVariantsBulkUpdate: { + productVariants: [ + { + id: "gid://shopify/ProductVariant/456", + price: "25.00", + compareAtPrice: null, + }, + ], + userErrors: [], + }, + }) + .mockResolvedValueOnce({ + productVariantsBulkUpdate: { + productVariants: [], + userErrors: [ + { + field: "price", + message: "Invalid price format", + }, + ], + }, + }); + + jest.spyOn(productService, "delay").mockResolvedValue(); + + const results = await productService.rollbackProductPrices(products); + + expect(results.totalProducts).toBe(1); + expect(results.totalVariants).toBe(2); + expect(results.successfulRollbacks).toBe(1); + expect(results.failedRollbacks).toBe(1); + expect(results.errors).toHaveLength(1); + + expect(results.errors[0]).toEqual( + expect.objectContaining({ + productId: "gid://shopify/Product/123", + productTitle: "Product 1", + variantId: "gid://shopify/ProductVariant/789", + errorMessage: "Shopify API errors: price: Invalid price format", + }) + ); + + expect(mockLogger.logRollbackUpdate).toHaveBeenCalledTimes(1); + expect(mockLogger.logProductError).toHaveBeenCalledTimes(1); + }); + + test("should handle variants that are skipped due to validation", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product 1", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 20.0, + compareAtPrice: 25.0, + title: "Valid Variant", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 30.0, + compareAtPrice: null, // Will be skipped + title: "Invalid Variant", + }, + ], + }, + ]; + + // Mock successful response for valid variant + mockShopifyService.executeWithRetry.mockResolvedValue({ + productVariantsBulkUpdate: { + productVariants: [ + { + id: "gid://shopify/ProductVariant/456", + price: "25.00", + compareAtPrice: null, + }, + ], + userErrors: [], + }, + }); + + jest.spyOn(productService, "delay").mockResolvedValue(); + + const results = await productService.rollbackProductPrices(products); + + expect(results.totalProducts).toBe(1); + expect(results.totalVariants).toBe(2); + expect(results.successfulRollbacks).toBe(1); + expect(results.failedRollbacks).toBe(0); + expect(results.skippedVariants).toBe(1); + + expect(mockLogger.logRollbackUpdate).toHaveBeenCalledTimes(1); + expect(mockLogger.warning).toHaveBeenCalledWith( + expect.stringContaining("Skipped variant") + ); + + // Only one API call should be made (for the valid variant) + expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(1); + }); + + test("should handle consecutive errors and stop processing", async () => { + // Create products that will all fail + const products = Array.from({ length: 10 }, (_, i) => ({ + id: `gid://shopify/Product/${i}`, + title: `Product ${i}`, + variants: [ + { + id: `gid://shopify/ProductVariant/${i}`, + price: 10.0 + i, + compareAtPrice: 15.0 + i, + title: `Variant ${i}`, + }, + ], + })); + + // Mock all calls to fail + mockShopifyService.executeWithRetry.mockRejectedValue( + new Error("Persistent API error") + ); + + jest.spyOn(productService, "delay").mockResolvedValue(); + + const results = await productService.rollbackProductPrices(products); + + // Should stop after 5 consecutive errors + expect(results.failedRollbacks).toBeLessThanOrEqual(5); + expect(results.errors.length).toBeGreaterThan(0); + + // Should log about stopping due to consecutive errors + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining("consecutive errors") + ); + }); + + test("should process products in batches with delays", async () => { + // Create products that exceed batch size + const products = Array.from({ length: 25 }, (_, i) => ({ + id: `gid://shopify/Product/${i}`, + title: `Product ${i}`, + variants: [ + { + id: `gid://shopify/ProductVariant/${i}`, + price: 10.0 + i, + compareAtPrice: 15.0 + i, + title: `Variant ${i}`, + }, + ], + })); + + mockShopifyService.executeWithRetry.mockResolvedValue({ + productVariantsBulkUpdate: { + productVariants: [ + { id: "test", price: "15.00", compareAtPrice: null }, + ], + userErrors: [], + }, + }); + + const delaySpy = jest.spyOn(productService, "delay").mockResolvedValue(); + + await productService.rollbackProductPrices(products); + + // Should have delays between batches (batch size is 10, so 3 batches total) + // Delays should be called 2 times (between batch 1-2 and 2-3) + expect(delaySpy).toHaveBeenCalledTimes(2); + expect(delaySpy).toHaveBeenCalledWith(500); + }); + + test("should handle empty products array for rollback", async () => { + const products = []; + + const results = await productService.rollbackProductPrices(products); + + expect(results).toEqual({ + totalProducts: 0, + totalVariants: 0, + eligibleVariants: 0, + successfulRollbacks: 0, + failedRollbacks: 0, + skippedVariants: 0, + errors: [], + }); + + expect(mockShopifyService.executeWithRetry).not.toHaveBeenCalled(); + }); + }); + + describe("Error Analysis and Categorization", () => { + test("should categorize different types of rollback errors", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Test Product", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 20.0, + compareAtPrice: 25.0, + title: "Test Variant", + }, + ], + }, + ]; + + // Mock different types of errors + 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.mockRejectedValue(rateLimitError); + jest.spyOn(productService, "delay").mockResolvedValue(); + + const results = await productService.rollbackProductPrices(products); + + expect(results.errors).toHaveLength(1); + expect(results.errors[0].errorMessage).toContain("Rate limit exceeded"); + expect(results.errors[0].errorHistory).toBeDefined(); + }); + }); + + describe("Progress Logging for Rollback", () => { + test("should log rollback progress correctly", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Test Product", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 20.0, + compareAtPrice: 25.0, + title: "Test Variant", + }, + ], + }, + ]; + + mockShopifyService.executeWithRetry.mockResolvedValue({ + productVariantsBulkUpdate: { + productVariants: [ + { + id: "gid://shopify/ProductVariant/456", + price: "25.00", + compareAtPrice: null, + }, + ], + userErrors: [], + }, + }); + + jest.spyOn(productService, "delay").mockResolvedValue(); + + await productService.rollbackProductPrices(products); + + expect(mockLogger.logRollbackUpdate).toHaveBeenCalledWith({ + productId: "gid://shopify/Product/123", + productTitle: "Test Product", + variantId: "gid://shopify/ProductVariant/456", + oldPrice: 20.0, + newPrice: 25.0, + compareAtPrice: 25.0, + }); + }); + }); + + describe("Error Handling Edge Cases", () => { + test("should handle product-level processing errors", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Test Product", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 20.0, + compareAtPrice: 25.0, + title: "Test Variant", + }, + ], + }, + ]; + + // Mock processProductForRollback to throw an error + jest + .spyOn(productService, "processProductForRollback") + .mockRejectedValue(new Error("Product processing failed")); + + jest.spyOn(productService, "delay").mockResolvedValue(); + + const results = await productService.rollbackProductPrices(products); + + expect(results.errors).toHaveLength(1); + expect(results.errors[0].errorMessage).toContain( + "Product processing failed" + ); + expect(results.errors[0].errorType).toBe("product_processing_error"); + }); + + test("should handle unexpected errors in variant processing", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Test Product", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 20.0, + compareAtPrice: 25.0, + title: "Test Variant", + }, + ], + }, + ]; + + // Mock rollbackVariantPrice to throw an unexpected error + jest + .spyOn(productService, "rollbackVariantPrice") + .mockRejectedValue(new Error("Unexpected error")); + + jest.spyOn(productService, "delay").mockResolvedValue(); + + const results = await productService.rollbackProductPrices(products); + + expect(results.errors).toHaveLength(1); + expect(results.errors[0].errorMessage).toContain( + "Unexpected rollback error" + ); + }); + }); + + describe("Existing Tests", () => { + test("should skip variants with invalid current prices", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With Invalid Current Prices", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: 39.99, + title: "Valid Variant", + }, + { + id: "gid://shopify/ProductVariant/789", + price: "invalid", + compareAtPrice: 24.99, + title: "Invalid Current Price", + }, + { + id: "gid://shopify/ProductVariant/101112", + price: NaN, + compareAtPrice: 19.99, + title: "NaN Current Price", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].variants).toHaveLength(1); + expect(eligibleProducts[0].variants[0].title).toBe("Valid Variant"); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "Invalid Current Price" in product "Product With Invalid Current Prices" for rollback - Invalid current price' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping variant "NaN Current Price" in product "Product With Invalid Current Prices" for rollback - Invalid current price' + ); + }); + + test("should skip products with no eligible variants", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Product With No Eligible Variants", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: null, + title: "No Compare-At Price", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 24.99, + compareAtPrice: 24.99, + title: "Same Price", + }, + ], + }, + { + id: "gid://shopify/Product/456", + title: "Product With Eligible Variants", + variants: [ + { + id: "gid://shopify/ProductVariant/101112", + price: 15.99, + compareAtPrice: 19.99, + title: "Valid Variant", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].title).toBe("Product With Eligible Variants"); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipping product "Product With No Eligible Variants" for rollback - no variants with valid compare-at prices' + ); + expect(mockLogger.warning).toHaveBeenCalledWith( + "Skipped 1 products during rollback validation" + ); + }); + + test("should handle empty product list for rollback validation", async () => { + const products = []; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(0); + expect(mockLogger.info).toHaveBeenCalledWith( + "Starting rollback validation for 0 products" + ); + expect(mockLogger.info).toHaveBeenCalledWith( + "Rollback validation completed: 0 products eligible (0/0 variants eligible)" + ); + }); + + test("should provide detailed validation statistics", async () => { + const products = [ + { + id: "gid://shopify/Product/123", + title: "Mixed Product", + variants: [ + { + id: "gid://shopify/ProductVariant/456", + price: 29.99, + compareAtPrice: 39.99, + title: "Valid Variant 1", + }, + { + id: "gid://shopify/ProductVariant/789", + price: 19.99, + compareAtPrice: 24.99, + title: "Valid Variant 2", + }, + { + id: "gid://shopify/ProductVariant/101112", + price: 14.99, + compareAtPrice: null, + title: "Invalid Variant", + }, + ], + }, + ]; + + const eligibleProducts = await productService.validateProductsForRollback( + products + ); + + expect(eligibleProducts).toHaveLength(1); + expect(eligibleProducts[0].variants).toHaveLength(2); + expect(mockLogger.info).toHaveBeenCalledWith( + "Rollback validation completed: 1 products eligible (2/3 variants eligible)" + ); + }); + }); + + describe("Rollback Operations", () => { + describe("rollbackVariantPrice", () => { + test("should rollback variant price successfully", async () => { + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 15.99, + compareAtPrice: 19.99, + title: "Test Variant", + }; + const productId = "gid://shopify/Product/456"; + + const mockResponse = { + productVariantsBulkUpdate: { + productVariants: [ + { + id: variant.id, + price: "19.99", + compareAtPrice: null, + }, + ], + userErrors: [], + }, + }; + + mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse); + + const result = await productService.rollbackVariantPrice( + variant, + productId + ); + + expect(result.success).toBe(true); + expect(result.rollbackDetails.oldPrice).toBe(15.99); + expect(result.rollbackDetails.compareAtPrice).toBe(19.99); + expect(result.rollbackDetails.newPrice).toBe(19.99); + expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(1); + }); + + test("should handle Shopify API errors during rollback", async () => { + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 15.99, + compareAtPrice: 19.99, + title: "Test Variant", + }; + const productId = "gid://shopify/Product/456"; + + const mockResponse = { + productVariantsBulkUpdate: { + productVariants: [], + userErrors: [ + { + field: "price", + message: "Price must be positive", + }, + ], + }, + }; + + mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse); + + const result = await productService.rollbackVariantPrice( + variant, + productId + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("Shopify API errors"); + expect(result.rollbackDetails.oldPrice).toBe(15.99); + expect(result.rollbackDetails.newPrice).toBe(null); + }); + + test("should handle network errors during rollback", async () => { + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 15.99, + compareAtPrice: 19.99, + title: "Test Variant", + }; + const productId = "gid://shopify/Product/456"; + + mockShopifyService.executeWithRetry.mockRejectedValue( + new Error("Network timeout") + ); + + const result = await productService.rollbackVariantPrice( + variant, + productId + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("Network timeout"); + }); + + test("should handle invalid variant for rollback", async () => { + const variant = { + id: "gid://shopify/ProductVariant/123", + price: 15.99, + compareAtPrice: null, // No compare-at price + title: "Test Variant", + }; + const productId = "gid://shopify/Product/456"; + + const result = await productService.rollbackVariantPrice( + variant, + productId + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("No compare-at price available"); + }); + }); + + describe("processProductForRollback", () => { + test("should process product with successful rollbacks", async () => { + const product = { + id: "gid://shopify/Product/456", + title: "Test Product", + variants: [ + { + id: "gid://shopify/ProductVariant/123", + price: 15.99, + compareAtPrice: 19.99, + title: "Variant 1", + }, + { + id: "gid://shopify/ProductVariant/124", + price: 25.99, + compareAtPrice: 29.99, + title: "Variant 2", + }, + ], + }; + + const results = { + totalVariants: 0, + successfulRollbacks: 0, + failedRollbacks: 0, + errors: [], + }; + + // Mock successful rollback responses + const mockResponse = { + productVariantsBulkUpdate: { + productVariants: [ + { id: "test", price: "19.99", compareAtPrice: null }, + ], + userErrors: [], + }, + }; + mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse); + + await productService.processProductForRollback(product, results); + + expect(results.totalVariants).toBe(2); + expect(results.successfulRollbacks).toBe(2); + expect(results.failedRollbacks).toBe(0); + expect(results.errors).toHaveLength(0); + expect(mockLogger.logRollbackUpdate).toHaveBeenCalledTimes(2); + }); + + test("should handle mixed success and failure scenarios", async () => { + const product = { + id: "gid://shopify/Product/456", + title: "Test Product", + variants: [ + { + id: "gid://shopify/ProductVariant/123", + price: 15.99, + compareAtPrice: 19.99, + title: "Valid Variant", + }, + { + id: "gid://shopify/ProductVariant/124", + price: 25.99, + compareAtPrice: null, // Invalid for rollback + title: "Invalid Variant", + }, + ], + }; + + const results = { + totalVariants: 0, + successfulRollbacks: 0, + failedRollbacks: 0, + skippedVariants: 0, + errors: [], + }; + + // Mock first call succeeds, second variant will be skipped due to validation + const successResponse = { + productVariantsBulkUpdate: { + productVariants: [ + { id: "test", price: "19.99", compareAtPrice: null }, + ], + userErrors: [], + }, + }; + mockShopifyService.executeWithRetry.mockResolvedValueOnce( + successResponse + ); + + await productService.processProductForRollback(product, results); + + expect(results.totalVariants).toBe(2); + expect(results.successfulRollbacks).toBe(1); + expect(results.failedRollbacks).toBe(0); + expect(results.skippedVariants).toBe(1); + expect(results.errors).toHaveLength(0); + expect(mockLogger.logRollbackUpdate).toHaveBeenCalledTimes(1); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Skipped variant "Invalid Variant" in product "Test Product": Rollback not eligible: No compare-at price available' + ); + }); + }); + + describe("rollbackProductPrices", () => { + test("should rollback multiple products successfully", async () => { + const products = [ + { + id: "gid://shopify/Product/456", + title: "Product 1", + variants: [ + { + id: "gid://shopify/ProductVariant/123", + price: 15.99, + compareAtPrice: 19.99, + title: "Variant 1", + }, + ], + }, + { + id: "gid://shopify/Product/789", + title: "Product 2", + variants: [ + { + id: "gid://shopify/ProductVariant/124", + price: 25.99, + compareAtPrice: 29.99, + title: "Variant 2", + }, + ], + }, + ]; + + // Mock successful responses + const mockResponse = { + productVariantsBulkUpdate: { + productVariants: [ + { id: "test", price: "19.99", compareAtPrice: null }, + ], + userErrors: [], + }, + }; + mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse); + + const results = await productService.rollbackProductPrices(products); + + expect(results.totalProducts).toBe(2); + expect(results.totalVariants).toBe(2); + expect(results.eligibleVariants).toBe(2); + expect(results.successfulRollbacks).toBe(2); + expect(results.failedRollbacks).toBe(0); + expect(results.errors).toHaveLength(0); + expect(mockLogger.info).toHaveBeenCalledWith( + "Starting price rollback for 2 products" + ); + expect(mockLogger.info).toHaveBeenCalledWith( + "Price rollback completed. Success: 2, Failed: 0, Skipped: 0, Success Rate: 100.0%" + ); + }); + + test("should handle empty product list", async () => { + const products = []; + + const results = await productService.rollbackProductPrices(products); + + expect(results.totalProducts).toBe(0); + expect(results.totalVariants).toBe(0); + expect(results.eligibleVariants).toBe(0); + expect(results.successfulRollbacks).toBe(0); + expect(results.failedRollbacks).toBe(0); + expect(mockShopifyService.executeWithRetry).not.toHaveBeenCalled(); + }); + + test("should process products in batches", async () => { + // Create more products than batch size (10) + const products = Array.from({ length: 15 }, (_, i) => ({ + id: `gid://shopify/Product/${i}`, + title: `Product ${i}`, + variants: [ + { + id: `gid://shopify/ProductVariant/${i}`, + price: 15.99, + compareAtPrice: 19.99, + title: `Variant ${i}`, + }, + ], + })); + + const mockResponse = { + productVariantsBulkUpdate: { + productVariants: [ + { id: "test", price: "19.99", compareAtPrice: null }, + ], + userErrors: [], + }, + }; + mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse); + + const results = await productService.rollbackProductPrices(products); + + expect(results.totalProducts).toBe(15); + expect(results.successfulRollbacks).toBe(15); + // Should log batch processing + expect(mockLogger.info).toHaveBeenCalledWith( + "Processing rollback batch 1 of 2 (10 products)" + ); + expect(mockLogger.info).toHaveBeenCalledWith( + "Processing rollback batch 2 of 2 (5 products)" + ); + }); + }); + }); }); diff --git a/tests/services/progress.test.js b/tests/services/progress.test.js index 76bc909..5957c4b 100644 --- a/tests/services/progress.test.js +++ b/tests/services/progress.test.js @@ -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 = [ diff --git a/tests/utils/logger.test.js b/tests/utils/logger.test.js new file mode 100644 index 0000000..45ae2c5 --- /dev/null +++ b/tests/utils/logger.test.js @@ -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") + ); + }); + }); + }); +}); diff --git a/tests/utils/price.test.js b/tests/utils/price.test.js index d61592e..b092617 100644 --- a/tests/utils/price.test.js +++ b/tests/utils/price.test.js @@ -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" + ); + }); + }); });