diff --git a/.aicodeprep-gui b/.aicodeprep-gui
deleted file mode 100644
index 9f84663..0000000
--- a/.aicodeprep-gui
+++ /dev/null
@@ -1,55 +0,0 @@
-# .aicodeprep-gui LLM/AI context helper settings file
-# This file stores your preferences (checked code files, window size) for this folder.
-# Generated by aicodeprep-gui.
-# Homepage: https://wuu73.org/aicp
-# GitHub: https://github.com/detroittommy879/aicodeprep-gui
-# ----------------------------------------------------------
-# aicodeprep-gui preferences file version 1.0
-version=1.0
-
-[window]
-width=1920
-height=1009
-splitter_state=AAAA/wAAAAEAAAACAAADAAAAAPwB/////wEAAAACAA==
-
-[format]
-output_format=xml
-
-[files]
-.env.example
-package.json
-README.md
-.kiro\specs\windows-compatible-tui\design.md
-.kiro\specs\windows-compatible-tui\requirements.md
-.kiro\specs\windows-compatible-tui\tasks.md
-.kiro\steering\product.md
-.kiro\steering\structure.md
-.kiro\steering\tech.md
-backend\.env
-docs\enhanced-signal-handling.md
-src\index.js
-src\tui-entry.js
-src\config\environment.js
-src\services\product.js
-src\services\progress.js
-src\services\schedule.js
-src\services\shopify.js
-src\tui\TuiApplication.jsx
-src\tui\components\Router.jsx
-src\tui\components\StatusBar.jsx
-src\tui\components\common\ProgressBar.js
-src\tui\providers\AppProvider.jsx
-src\utils\logger.js
-src\utils\price.js
-tests\index.test.js
-tests\config\environment.test.js
-tests\integration\rollback-workflow.test.js
-tests\integration\scheduled-execution-workflow.test.js
-tests\services\product.test.js
-tests\services\progress.test.js
-tests\services\schedule-error-handling.test.js
-tests\services\schedule-signal-handling.test.js
-tests\services\schedule.test.js
-tests\services\shopify.test.js
-tests\utils\logger.test.js
-tests\utils\price.test.js
diff --git a/fullcode.txt b/fullcode.txt
deleted file mode 100644
index fa77ba5..0000000
--- a/fullcode.txt
+++ /dev/null
@@ -1,16032 +0,0 @@
-.kiro\specs\windows-compatible-tui\design.md:
-
-# Design Document
-
-## Overview
-
-This design document outlines the replacement of the Blessed-based TUI with a Windows-compatible alternative using **Ink** (React for CLI) as the primary library choice. Ink provides excellent cross-platform support, modern React-based component architecture, and superior Windows compatibility compared to Blessed. The design maintains all existing functionality while improving performance, maintainability, and user experience across all platforms.
-
-## Architecture
-
-### Library Selection: Ink (React for CLI)
-
-**Primary Choice: Ink v4.x**
-
-- **Rationale**: Ink is built on React principles, providing a modern component-based architecture
-- **Windows Compatibility**: Excellent support for Windows Terminal, Command Prompt, and PowerShell
-- **Performance**: Uses React's reconciliation for efficient updates, reducing flicker
-- **Ecosystem**: Large ecosystem of pre-built components and utilities
-- **Maintenance**: Actively maintained by Vercel with strong community support
-
-**Alternative Considerations**:
-
-- **Blessed**: Current library with Windows issues (being replaced)
-- **Terminal-kit**: Good Windows support but more complex API
-- **Enquirer**: Limited to prompts, not full TUI applications
-- **Neo-blessed**: Fork of Blessed with some improvements but still has Windows issues
-
-### Component Architecture
-
-```
-TuiApplication (Root)
-├── AppProvider (Context/State Management)
-├── Router (Screen Management)
-├── StatusBar (Global Status)
-└── Screens/
- ├── MainMenuScreen
- ├── ConfigurationScreen
- ├── OperationScreen
- ├── SchedulingScreen
- ├── LogViewerScreen
- └── TagAnalysisScreen
-```
-
-### State Management
-
-Using React Context API with custom hooks for:
-
-- Application state (current screen, navigation history)
-- Configuration state (environment variables, settings)
-- Operation state (progress, results, errors)
-- UI state (focus, selections, modal states)
-
-## Components and Interfaces
-
-### Core Components
-
-#### 1. TuiApplication (Root Component)
-
-```javascript
-const TuiApplication = () => {
- return (
-
-
-
-
-
-
- );
-};
-```
-
-#### 2. AppProvider (State Management)
-
-```javascript
-const AppProvider = ({ children }) => {
- const [appState, setAppState] = useState({
- currentScreen: "main-menu",
- navigationHistory: [],
- configuration: {},
- operationState: null,
- });
-
- return (
-
- {children}
-
- );
-};
-```
-
-#### 3. Router (Screen Management)
-
-```javascript
-const Router = () => {
- const { appState } = useContext(AppContext);
-
- const screens = {
- "main-menu": MainMenuScreen,
- configuration: ConfigurationScreen,
- operation: OperationScreen,
- scheduling: SchedulingScreen,
- logs: LogViewerScreen,
- "tag-analysis": TagAnalysisScreen,
- };
-
- const CurrentScreen = screens[appState.currentScreen];
- return ;
-};
-```
-
-#### 4. StatusBar (Global Status Display)
-
-```javascript
-const StatusBar = () => {
- const { connectionStatus, operationProgress } = useAppState();
-
- return (
-
- ● Connected
- |
- Progress: {operationProgress}%
-
- );
-};
-```
-
-### Screen Components
-
-#### MainMenuScreen
-
-- Navigation menu with keyboard shortcuts
-- Current configuration summary
-- Quick action buttons
-- Help information
-
-#### ConfigurationScreen
-
-- Environment variable editor
-- Input validation with real-time feedback
-- API connection testing
-- Save/cancel operations
-
-#### OperationScreen
-
-- Operation type selection (update/rollback)
-- Real-time progress display
-- Product processing information
-- Error handling and display
-
-#### SchedulingScreen
-
-- Date/time picker interface
-- Schedule management
-- Countdown display
-- Cancellation controls
-
-#### LogViewerScreen
-
-- Paginated log display
-- Search and filtering
-- Log entry details
-- Export functionality
-
-#### TagAnalysisScreen
-
-- Tag listing and statistics
-- Product count per tag
-- Sample product display
-- Recommendations
-
-### Reusable UI Components
-
-#### ProgressBar
-
-```javascript
-const ProgressBar = ({ progress, label, color = "blue" }) => {
- const width = 40;
- const filled = Math.round((progress / 100) * width);
-
- return (
-
- {label}
-
- {"█".repeat(filled)}
- {"░".repeat(width - filled)}
- {progress}%
-
-
- );
-};
-```
-
-#### InputField
-
-```javascript
-const InputField = ({ label, value, onChange, validation, placeholder }) => {
- const [isValid, setIsValid] = useState(true);
-
- return (
-
- {label}:
- {
- onChange(val);
- setIsValid(validation ? validation(val) : true);
- }}
- placeholder={placeholder}
- />
- {!isValid && Invalid input}
-
- );
-};
-```
-
-#### MenuList
-
-```javascript
-const MenuList = ({ items, selectedIndex, onSelect }) => {
- return (
-
- {items.map((item, index) => (
-
-
- {index === selectedIndex ? "► " : " "}
- {item.label}
-
-
- ))}
-
- );
-};
-```
-
-## Data Models
-
-### Application State
-
-```javascript
-interface AppState {
- currentScreen: string;
- navigationHistory: string[];
- configuration: ConfigurationState;
- operationState: OperationState | null;
- uiState: UIState;
-}
-
-interface ConfigurationState {
- shopifyDomain: string;
- accessToken: string;
- targetTag: string;
- priceAdjustment: number;
- operationMode: "update" | "rollback";
- isValid: boolean;
- lastTested: Date | null;
-}
-
-interface OperationState {
- type: "update" | "rollback" | "scheduled";
- status: "idle" | "running" | "completed" | "error";
- progress: number;
- currentProduct: string | null;
- results: OperationResults | null;
- errors: Error[];
-}
-
-interface UIState {
- focusedComponent: string;
- modalOpen: boolean;
- selectedMenuIndex: number;
- scrollPosition: number;
-}
-```
-
-### Service Integration
-
-```javascript
-interface ServiceIntegration {
- shopifyService: ShopifyService;
- productService: ProductService;
- progressService: ProgressService;
- configService: ConfigurationService;
-}
-```
-
-## Error Handling
-
-### Error Categories
-
-1. **Configuration Errors**: Invalid environment variables, API credentials
-2. **Network Errors**: Connection failures, timeout issues
-3. **API Errors**: Shopify API rate limits, authentication failures
-4. **UI Errors**: Component rendering issues, state inconsistencies
-5. **System Errors**: File system access, permission issues
-
-### Error Display Strategy
-
-```javascript
-const ErrorBoundary = ({ children }) => {
- const [error, setError] = useState(null);
-
- if (error) {
- return (
-
-
- Error Occurred
-
- {error.message}
- Press 'r' to retry or 'q' to quit
-
- );
- }
-
- return children;
-};
-```
-
-### Graceful Degradation
-
-- Fallback to basic text display if advanced features fail
-- Automatic retry mechanisms for network operations
-- State persistence to recover from crashes
-- Clear error messages with suggested actions
-
-## Testing Strategy
-
-### Component Testing
-
-```javascript
-// Example test using Ink's testing utilities
-import { render } from "ink-testing-library";
-import { MainMenuScreen } from "../screens/MainMenuScreen";
-
-test("renders main menu with correct options", () => {
- const { lastFrame } = render();
- expect(lastFrame()).toContain("Price Update Operations");
- expect(lastFrame()).toContain("Configuration");
- expect(lastFrame()).toContain("View Logs");
-});
-```
-
-### Integration Testing
-
-- Test service integration with mock services
-- Verify state management across screen transitions
-- Test keyboard navigation and input handling
-- Validate error handling scenarios
-
-### Cross-Platform Testing
-
-- Automated testing on Windows, macOS, and Linux
-- Terminal compatibility testing (Windows Terminal, Command Prompt, PowerShell)
-- Unicode and color support verification
-- Performance testing with large datasets
-
-## Migration Strategy
-
-### Phase 1: Setup and Core Infrastructure
-
-1. Install Ink and related dependencies
-2. Create basic application structure
-3. Implement state management system
-4. Set up routing and navigation
-
-### Phase 2: Screen Implementation
-
-1. Implement MainMenuScreen (simplest)
-2. Create ConfigurationScreen with form handling
-3. Build OperationScreen with progress display
-4. Add remaining screens (Scheduling, Logs, TagAnalysis)
-
-### Phase 3: Component Migration
-
-1. Replace Blessed ProgressBar with Ink version
-2. Migrate form components and input handling
-3. Update navigation and keyboard shortcuts
-4. Implement error handling and validation
-
-### Phase 4: Testing and Refinement
-
-1. Comprehensive testing on Windows systems
-2. Performance optimization and bug fixes
-3. Documentation updates
-4. Legacy code cleanup
-
-### Dependency Changes
-
-```json
-{
- "dependencies": {
- "ink": "^4.4.1",
- "react": "^18.2.0",
- "@ink/text-input": "^5.0.1",
- "@ink/select-input": "^5.0.1",
- "@ink/spinner": "^5.0.1"
- },
- "devDependencies": {
- "ink-testing-library": "^3.0.0"
- }
-}
-```
-
-### File Structure Changes
-
-```
-src/
-├── tui/
-│ ├── components/
-│ │ ├── common/
-│ │ │ ├── ProgressBar.jsx
-│ │ │ ├── InputField.jsx
-│ │ │ ├── MenuList.jsx
-│ │ │ └── ErrorBoundary.jsx
-│ │ ├── screens/
-│ │ │ ├── MainMenuScreen.jsx
-│ │ │ ├── ConfigurationScreen.jsx
-│ │ │ ├── OperationScreen.jsx
-│ │ │ ├── SchedulingScreen.jsx
-│ │ │ ├── LogViewerScreen.jsx
-│ │ │ └── TagAnalysisScreen.jsx
-│ │ └── providers/
-│ │ ├── AppProvider.jsx
-│ │ └── ServiceProvider.jsx
-│ ├── hooks/
-│ │ ├── useAppState.js
-│ │ ├── useNavigation.js
-│ │ └── useServices.js
-│ ├── utils/
-│ │ ├── keyboardHandlers.js
-│ │ └── validation.js
-│ └── TuiApplication.jsx
-└── tui-entry.js (new entry point)
-```
-
-## Performance Considerations
-
-### Rendering Optimization
-
-- Use React.memo for expensive components
-- Implement virtual scrolling for large lists
-- Debounce rapid state updates
-- Minimize re-renders with proper state structure
-
-### Memory Management
-
-- Clean up event listeners and timers
-- Implement proper component unmounting
-- Use weak references for large data structures
-- Monitor memory usage during long operations
-
-### Windows-Specific Optimizations
-
-- Use Windows-compatible Unicode characters
-- Optimize for Windows Terminal performance
-- Handle Windows-specific keyboard events
-- Ensure proper color rendering in different terminals
-
-## Security Considerations
-
-### Input Validation
-
-- Sanitize all user inputs
-- Validate configuration values
-- Prevent injection attacks through input fields
-- Secure handling of API credentials
-
-### State Security
-
-- Encrypt sensitive data in state
-- Clear sensitive information on exit
-- Prevent credential logging
-- Secure temporary file handling
-
-This design provides a robust foundation for replacing Blessed with Ink, ensuring excellent Windows compatibility while maintaining all existing functionality and improving the overall user experience.
-
-
-
-.kiro\specs\windows-compatible-tui\requirements.md:
-
-# Requirements Document
-
-## Introduction
-
-This document outlines the requirements for replacing the existing Blessed-based Terminal User Interface (TUI) with a Windows-compatible alternative. The current TUI implementation using the Blessed library has compatibility issues on Windows systems, requiring a migration to a more robust, cross-platform TUI library that provides better Windows support while maintaining all existing functionality and user experience expectations.
-
-## Requirements
-
-### Requirement 1
-
-**User Story:** As a Windows user, I want a TUI that works reliably on my system, so that I can use the interactive interface without compatibility issues.
-
-#### Acceptance Criteria
-
-1. WHEN the TUI is launched on Windows THEN the system SHALL display correctly without rendering artifacts
-2. WHEN using Windows Terminal or Command Prompt THEN the system SHALL handle keyboard input properly
-3. WHEN the interface renders THEN the system SHALL display Unicode characters and colors correctly on Windows
-4. WHEN resizing the terminal window THEN the system SHALL adapt the layout appropriately
-5. WHEN using different Windows terminal emulators THEN the system SHALL maintain consistent behavior
-
-### Requirement 2
-
-**User Story:** As a developer, I want to replace Blessed with a better cross-platform TUI library, so that the application works consistently across all operating systems.
-
-#### Acceptance Criteria
-
-1. WHEN selecting a replacement library THEN the system SHALL prioritize Windows compatibility
-2. WHEN the new library is integrated THEN the system SHALL maintain feature parity with the Blessed implementation
-3. WHEN the library is chosen THEN the system SHALL have active maintenance and good documentation
-4. WHEN implementing the replacement THEN the system SHALL support modern terminal features
-5. WHEN the migration is complete THEN the system SHALL remove all Blessed dependencies
-
-### Requirement 3
-
-**User Story:** As a user, I want the same TUI functionality after the library replacement, so that my workflow remains unchanged.
-
-#### Acceptance Criteria
-
-1. WHEN the new TUI loads THEN the system SHALL display the same main menu structure
-2. WHEN navigating the interface THEN the system SHALL support the same keyboard shortcuts
-3. WHEN configuring settings THEN the system SHALL provide the same configuration options
-4. WHEN running operations THEN the system SHALL show the same progress indicators
-5. WHEN viewing logs THEN the system SHALL display the same information format
-
-### Requirement 4
-
-**User Story:** As a user, I want improved performance and responsiveness in the new TUI, so that the interface feels more fluid and responsive.
-
-#### Acceptance Criteria
-
-1. WHEN the TUI starts THEN the system SHALL load faster than the Blessed version
-2. WHEN updating progress displays THEN the system SHALL render smoothly without flickering
-3. WHEN handling large amounts of log data THEN the system SHALL maintain responsive scrolling
-4. WHEN switching between screens THEN the system SHALL transition quickly
-5. WHEN processing user input THEN the system SHALL respond immediately
-
-### Requirement 5
-
-**User Story:** As a developer, I want the new TUI implementation to follow modern JavaScript patterns, so that the code is maintainable and extensible.
-
-#### Acceptance Criteria
-
-1. WHEN implementing components THEN the system SHALL use ES6+ features and modern patterns
-2. WHEN structuring the code THEN the system SHALL follow the existing project architecture
-3. WHEN handling state THEN the system SHALL use clear state management patterns
-4. WHEN implementing event handling THEN the system SHALL use consistent event patterns
-5. WHEN writing tests THEN the system SHALL provide good test coverage for TUI components
-
-### Requirement 6
-
-**User Story:** As a user, I want enhanced visual feedback and better error handling in the new TUI, so that I have a clearer understanding of system status.
-
-#### Acceptance Criteria
-
-1. WHEN errors occur THEN the system SHALL display more informative error messages
-2. WHEN operations are running THEN the system SHALL provide clearer progress visualization
-3. WHEN configuration is invalid THEN the system SHALL highlight specific issues
-4. WHEN API calls fail THEN the system SHALL show detailed connection status
-5. WHEN the system is busy THEN the system SHALL provide appropriate loading indicators
-
-### Requirement 7
-
-**User Story:** As a developer, I want the migration to preserve all existing service integrations, so that business logic remains unchanged.
-
-#### Acceptance Criteria
-
-1. WHEN the new TUI is implemented THEN the system SHALL reuse existing ShopifyService without changes
-2. WHEN operations run THEN the system SHALL use existing ProductService and ProgressService
-3. WHEN configuration is managed THEN the system SHALL use existing environment configuration
-4. WHEN logs are generated THEN the system SHALL maintain compatibility with existing log formats
-5. WHEN the migration is complete THEN the system SHALL pass all existing integration tests
-
-### Requirement 8
-
-**User Story:** As a user, I want better accessibility features in the new TUI, so that the interface is more inclusive and easier to use.
-
-#### Acceptance Criteria
-
-1. WHEN using screen readers THEN the system SHALL provide appropriate text descriptions
-2. WHEN using high contrast mode THEN the system SHALL adapt color schemes appropriately
-3. WHEN using keyboard-only navigation THEN the system SHALL provide clear focus indicators
-4. WHEN text is displayed THEN the system SHALL support different font sizes and terminal settings
-5. WHEN colors are used THEN the system SHALL ensure sufficient contrast ratios
-
-### Requirement 9
-
-**User Story:** As a developer, I want comprehensive documentation for the new TUI library choice, so that future maintenance is straightforward.
-
-#### Acceptance Criteria
-
-1. WHEN the library is selected THEN the system SHALL document the selection rationale
-2. WHEN implementation patterns are established THEN the system SHALL document coding conventions
-3. WHEN components are created THEN the system SHALL include inline documentation
-4. WHEN the migration is complete THEN the system SHALL update all relevant README files
-5. WHEN troubleshooting guides are needed THEN the system SHALL provide Windows-specific guidance
-
-### Requirement 10
-
-**User Story:** As a user, I want the new TUI to handle terminal resizing and different screen sizes better, so that I can use it on various display configurations.
-
-#### Acceptance Criteria
-
-1. WHEN the terminal is resized THEN the system SHALL automatically adjust layout proportions
-2. WHEN using small terminal windows THEN the system SHALL provide appropriate scrolling
-3. WHEN using large displays THEN the system SHALL utilize available space effectively
-4. WHEN switching between portrait and landscape orientations THEN the system SHALL adapt accordingly
-5. WHEN minimum size requirements aren't met THEN the system SHALL display helpful guidance
-
-### Requirement 11
-
-**User Story:** As a developer, I want a smooth migration path from Blessed to the new library, so that the transition minimizes disruption.
-
-#### Acceptance Criteria
-
-1. WHEN planning the migration THEN the system SHALL identify all Blessed-specific code
-2. WHEN implementing replacements THEN the system SHALL maintain API compatibility where possible
-3. WHEN testing the migration THEN the system SHALL verify functionality on multiple Windows versions
-4. WHEN deploying the changes THEN the system SHALL provide fallback options if issues arise
-5. WHEN the migration is complete THEN the system SHALL clean up all legacy Blessed code
-
-### Requirement 12
-
-**User Story:** As a user, I want the new TUI to support modern terminal features, so that I can take advantage of enhanced terminal capabilities.
-
-#### Acceptance Criteria
-
-1. WHEN using modern terminals THEN the system SHALL support true color (24-bit) display
-2. WHEN terminals support it THEN the system SHALL use enhanced Unicode characters
-3. WHEN available THEN the system SHALL support mouse interaction for navigation
-4. WHEN terminals provide it THEN the system SHALL use improved cursor positioning
-5. WHEN modern features are unavailable THEN the system SHALL gracefully degrade functionality
-
-
-
-.kiro\specs\windows-compatible-tui\tasks.md:
-
-# Implementation Plan
-
-- [ ] 1. Setup Ink infrastructure and remove Blessed dependencies
-
- - Remove blessed dependency from package.json and install Ink dependencies
- - Create new TUI entry point file that initializes Ink application
- - Set up basic React component structure with JSX support
- - _Requirements: 2.2, 2.5_
-
-- [ ] 2. Implement core application structure and state management
-
- - Create AppProvider component with React Context for global state management
- - Implement Router component for screen navigation and history management
- - Create useAppState and useNavigation custom hooks for state access
- - Write unit tests for state management and navigation logic
- - _Requirements: 5.1, 5.3, 7.1_
-
-- [ ] 3. Build reusable UI components
-- [ ] 3.1 Create ProgressBar component with Ink
-
- - Replace Blessed ProgressBar with Ink-based implementation using Box and Text components
- - Add support for different colors, labels, and progress indicators
- - Write unit tests for ProgressBar component rendering and updates
- - _Requirements: 3.1, 4.2, 6.2_
-
-- [ ] 3.2 Implement InputField component with validation
-
- - Create InputField component using Ink's TextInput with validation support
- - Add real-time validation feedback and error message display
- - Write unit tests for input validation and error handling
- - _Requirements: 3.2, 6.3, 8.3_
-
-- [ ] 3.3 Create MenuList component for navigation
-
- - Implement MenuList component with keyboard navigation support
- - Add selection highlighting and keyboard shortcut display
- - Write unit tests for menu navigation and selection handling
- - _Requirements: 1.2, 9.3, 9.4_
-
-- [ ] 3.4 Build ErrorBoundary component for error handling
-
- - Create ErrorBoundary component to catch and display React errors gracefully
- - Implement error recovery mechanisms and user-friendly error messages
- - Write unit tests for error boundary functionality
- - _Requirements: 6.1, 10.4, 11.4_
-
-- [ ] 4. Implement StatusBar component
-
- - Create StatusBar component showing connection status and operation progress
- - Integrate with existing services to display real-time system status
- - Add support for different status indicators and colors
- - Write unit tests for status display and updates
- - _Requirements: 8.1, 8.2, 8.3_
-
-- [ ] 5. Create MainMenuScreen component
-
- - Implement MainMenuScreen as the primary navigation interface
- - Add keyboard shortcuts and menu options matching existing TUI requirements
- - Integrate with navigation system for screen transitions
- - Write unit tests for menu functionality and navigation
- - _Requirements: 1.1, 1.3, 3.1, 9.1_
-
-- [ ] 6. Build ConfigurationScreen component
-- [ ] 6.1 Create configuration form interface
-
- - Implement ConfigurationScreen with form fields for all environment variables
- - Add input validation and real-time feedback for configuration values
- - Write unit tests for form validation and state management
- - _Requirements: 2.1, 2.2, 2.4_
-
-- [ ] 6.2 Implement configuration persistence
-
- - Add functionality to save configuration changes to .env file
- - Implement configuration loading and validation on screen load
- - Write unit tests for configuration file operations
- - _Requirements: 2.3, 7.4, 11.4_
-
-- [ ] 6.3 Add API connection testing
-
- - Integrate Shopify API connection testing within configuration screen
- - Display connection status and error messages for failed connections
- - Write unit tests for API connection testing functionality
- - _Requirements: 2.5, 6.4, 8.1_
-
-- [ ] 7. Implement OperationScreen component
-- [ ] 7.1 Create operation selection interface
-
- - Build OperationScreen with update/rollback operation selection
- - Display current configuration summary before operation execution
- - Write unit tests for operation selection and configuration display
- - _Requirements: 3.1, 4.1, 7.2_
-
-- [ ] 7.2 Add real-time progress display
-
- - Implement real-time progress indicators using ProgressBar component
- - Display current product information and processing status
- - Write unit tests for progress display and updates
- - _Requirements: 3.2, 3.3, 4.2, 8.2_
-
-- [ ] 7.3 Integrate operation results display
-
- - Add results summary display for completed operations
- - Implement error display panel for operation failures
- - Write unit tests for results display and error handling
- - _Requirements: 3.4, 3.5, 4.3, 6.1_
-
-- [ ] 8. Build SchedulingScreen component
-- [ ] 8.1 Create scheduling interface
-
- - Implement SchedulingScreen with date/time picker functionality
- - Add schedule management and countdown timer display
- - Write unit tests for scheduling interface and timer functionality
- - _Requirements: 5.1, 5.2, 5.3_
-
-- [ ] 8.2 Add schedule cancellation and notifications
-
- - Implement schedule cancellation with confirmation dialog
- - Add visual notifications for approaching scheduled operations
- - Write unit tests for cancellation and notification systems
- - _Requirements: 5.4, 5.5_
-
-- [ ] 9. Create LogViewerScreen component
-- [ ] 9.1 Implement log display with pagination
-
- - Build LogViewerScreen with paginated log entry display
- - Add scrolling support for large log files
- - Write unit tests for log display and pagination
- - _Requirements: 6.1, 6.4, 10.3_
-
-- [ ] 9.2 Add log filtering and search functionality
-
- - Implement search and filtering capabilities for log entries
- - Add detailed view for selected log entries
- - Write unit tests for search and filtering functionality
- - _Requirements: 6.2, 6.3_
-
-- [ ] 9.3 Integrate automatic log refresh
-
- - Add automatic refresh functionality for active log monitoring
- - Implement efficient update mechanisms to avoid performance issues
- - Write unit tests for automatic refresh and performance
- - _Requirements: 6.5, 4.3_
-
-- [ ] 10. Build TagAnalysisScreen component
-- [ ] 10.1 Create tag analysis interface
-
- - Implement TagAnalysisScreen displaying available product tags and counts
- - Add sample product display for selected tags
- - Write unit tests for tag analysis display and selection
- - _Requirements: 7.1, 7.2, 7.3_
-
-- [ ] 10.2 Add tag recommendations
-
- - Implement recommendation system for optimal target tags
- - Display analysis results and suggestions to users
- - Write unit tests for recommendation logic and display
- - _Requirements: 7.4_
-
-- [ ] 11. Implement keyboard navigation and shortcuts
-- [ ] 11.1 Add global keyboard handlers
-
- - Create keyboard event handlers for navigation and shortcuts
- - Implement consistent back/exit functionality across all screens
- - Write unit tests for keyboard navigation and event handling
- - _Requirements: 9.1, 9.3, 9.4_
-
-- [ ] 11.2 Create help system
-
- - Implement help overlay displaying available shortcuts and navigation
- - Add context-sensitive help for different screens
- - Write unit tests for help system functionality
- - _Requirements: 9.2, 9.5_
-
-- [ ] 12. Integrate with existing services
-- [ ] 12.1 Connect TUI to ShopifyService
-
- - Integrate TUI components with existing ShopifyService for API operations
- - Ensure all API calls use existing service methods without modification
- - Write integration tests for service connectivity
- - _Requirements: 7.1, 12.1_
-
-- [ ] 12.2 Connect TUI to ProductService and ProgressService
-
- - Integrate TUI with existing ProductService for product operations
- - Connect ProgressService for logging and progress tracking
- - Write integration tests for service integration
- - _Requirements: 7.2, 12.2, 12.3_
-
-- [ ] 12.3 Maintain CLI compatibility
-
- - Ensure TUI implementation doesn't break existing CLI functionality
- - Verify that both interfaces can coexist and use same configuration
- - Write integration tests for CLI/TUI compatibility
- - _Requirements: 12.3, 12.4_
-
-- [ ] 13. Implement responsive layout and terminal handling
-- [ ] 13.1 Add terminal resize handling
-
- - Implement automatic layout adjustment for terminal resize events
- - Add minimum size requirements and appropriate messaging
- - Write unit tests for resize handling and layout adaptation
- - _Requirements: 10.1, 10.2, 10.5_
-
-- [ ] 13.2 Optimize for different screen sizes
-
- - Implement responsive design for small and large terminal windows
- - Add scrolling support where needed for content overflow
- - Write unit tests for different screen size scenarios
- - _Requirements: 10.2, 10.3, 10.4_
-
-- [ ] 14. Add accessibility and modern terminal features
-- [ ] 14.1 Implement accessibility features
-
- - Add screen reader support and high contrast mode compatibility
- - Implement clear focus indicators for keyboard navigation
- - Write tests for accessibility features
- - _Requirements: 8.1, 8.2, 8.3_
-
-- [ ] 14.2 Add modern terminal feature support
-
- - Implement true color support and enhanced Unicode character usage
- - Add mouse interaction support where appropriate
- - Write tests for modern terminal feature detection and usage
- - _Requirements: 12.1, 12.2, 12.3_
-
-- [ ] 15. Performance optimization and testing
-- [ ] 15.1 Optimize rendering performance
-
- - Implement React.memo for expensive components and virtual scrolling for large lists
- - Add debouncing for rapid state updates and minimize unnecessary re-renders
- - Write performance tests and benchmarks
- - _Requirements: 4.1, 4.3, 4.4_
-
-- [ ] 15.2 Add memory management
-
- - Implement proper cleanup for event listeners and timers
- - Add memory usage monitoring for long-running operations
- - Write tests for memory leak detection and cleanup
- - _Requirements: 4.2, 4.5_
-
-- [ ] 16. Cross-platform testing and Windows optimization
-- [ ] 16.1 Test Windows compatibility
-
- - Run comprehensive tests on Windows Terminal, Command Prompt, and PowerShell
- - Verify Unicode character rendering and color support on Windows
- - Write Windows-specific integration tests
- - _Requirements: 1.1, 1.2, 1.3, 1.4_
-
-- [ ] 16.2 Optimize for Windows performance
-
- - Implement Windows-specific optimizations for terminal rendering
- - Add Windows-specific keyboard event handling
- - Write performance tests specifically for Windows environments
- - _Requirements: 1.5, 4.4_
-
-- [ ] 17. Documentation and migration cleanup
-- [ ] 17.1 Update documentation
-
- - Update README files with new TUI library information and setup instructions
- - Document new component architecture and development patterns
- - Create troubleshooting guide for Windows-specific issues
- - _Requirements: 9.1, 9.2, 9.4_
-
-- [ ] 17.2 Clean up legacy Blessed code
- - Remove all Blessed dependencies and related code files
- - Clean up any remaining references to Blessed in documentation
- - Verify complete migration through final testing
- - _Requirements: 2.5, 11.5_
-
-
-
-.kiro\steering\product.md:
-
-# Product Overview
-
-## Shopify Price Updater
-
-A Node.js command-line tool for bulk updating Shopify product prices based on product tags using Shopify's GraphQL Admin API.
-
-### Core Features
-
-- **Tag-based filtering**: Updates prices only for products with specific tags
-- **Dual operation modes**:
- - `update`: Adjusts prices by percentage and sets compare-at prices
- - `rollback`: Reverts prices using compare-at price values
-- **Batch processing**: Handles large inventories with automatic pagination
-- **Error resilience**: Continues processing with comprehensive error handling
-- **Rate limit handling**: Automatic retry logic with exponential backoff
-- **Progress tracking**: Detailed logging to console and Progress.md file
-
-### Target Users
-
-- Shopify store owners managing promotional pricing
-- E-commerce teams running seasonal sales campaigns
-- Developers automating price management workflows
-
-### Key Value Propositions
-
-- Reduces manual price update effort from hours to minutes
-- Provides rollback capability for promotional campaigns
-- Maintains data integrity with validation and error handling
-- Offers detailed audit trails through comprehensive logging
-
-
-
-.kiro\steering\structure.md:
-
-# Project Structure
-
-## Directory Organization
-
-```
-shopify-price-updater/
-├── src/ # Main application source code
-│ ├── config/ # Configuration management
-│ │ └── environment.js # Environment variable validation & loading
-│ ├── services/ # Business logic services
-│ │ ├── shopify.js # Shopify API client & GraphQL operations
-│ │ ├── product.js # Product operations & price calculations
-│ │ └── progress.js # Progress tracking & logging service
-│ ├── utils/ # Utility functions
-│ │ ├── price.js # Price calculation & validation utilities
-│ │ └── logger.js # Logging utilities & formatters
-│ └── index.js # Main application entry point
-├── tests/ # Test suites
-│ ├── config/ # Configuration tests
-│ ├── services/ # Service layer tests
-│ ├── utils/ # Utility function tests
-│ ├── integration/ # Integration tests
-│ └── index.test.js # Main application tests
-├── backend/ # Separate backend component (minimal)
-│ └── .env # Backend-specific environment config
-├── .env # Main environment configuration
-├── .env.example # Environment template
-├── Progress.md # Generated operation logs
-├── debug-tags.js # Standalone tag analysis script
-└── test-*.js # Individual test scripts
-```
-
-## Code Organization Patterns
-
-### Service Layer Structure
-
-- **ShopifyService**: API client, authentication, retry logic
-- **ProductService**: Product fetching, validation, price updates
-- **ProgressService**: Logging, progress tracking, file operations
-
-### Configuration Management
-
-- Centralized in `src/config/environment.js`
-- Validation at startup with clear error messages
-- Environment-specific defaults and overrides
-
-### Error Handling Strategy
-
-- Service-level error categorization (rate limits, network, validation)
-- Comprehensive retry logic with exponential backoff
-- Detailed error logging with context preservation
-
-### Testing Structure
-
-- Unit tests for utilities and individual services
-- Integration tests for complete workflows
-- Mock-based testing for external API dependencies
-- Separate test files for different operation modes
-
-## File Naming Conventions
-
-- **Services**: `[domain].js` (e.g., `shopify.js`, `product.js`)
-- **Utilities**: `[function].js` (e.g., `price.js`, `logger.js`)
-- **Tests**: `[module].test.js` or `test-[feature].js`
-- **Configuration**: Descriptive names (e.g., `environment.js`)
-
-## Entry Points
-
-- **Main Application**: `src/index.js` - Complete price update workflow
-- **Debug Tools**: `debug-tags.js` - Tag analysis and troubleshooting
-- **Test Scripts**: `test-*.js` - Individual feature testing
-
-
-
-.kiro\steering\tech.md:
-
-# Technology Stack
-
-## Runtime & Dependencies
-
-- **Node.js**: >=16.0.0 (specified in package.json engines)
-- **Core Dependencies**:
- - `@shopify/shopify-api`: ^7.7.0 - Shopify GraphQL API client
- - `dotenv`: ^16.3.1 - Environment variable management
- - `node-fetch`: ^3.3.2 - HTTP client for API requests
-- **Development Dependencies**:
- - `jest`: ^29.7.0 - Testing framework
-
-## Architecture Patterns
-
-- **Service Layer Architecture**: Separation of concerns with dedicated services
-- **Configuration Management**: Centralized environment-based configuration
-- **Error Handling**: Comprehensive retry logic with exponential backoff
-- **Logging Strategy**: Dual output (console + Progress.md file)
-
-## API Integration
-
-- **Shopify GraphQL Admin API**: Version 2024-01
-- **Authentication**: Private App access tokens
-- **Rate Limiting**: Built-in handling with automatic retries
-- **Bulk Operations**: Uses `productVariantsBulkUpdate` mutation
-
-## Common Commands
-
-### Development
-
-```bash
-npm install # Install dependencies
-npm start # Run with default settings
-npm run update # Explicit update mode
-npm run rollback # Rollback mode
-npm run debug-tags # Debug tag analysis
-npm test # Run test suite
-```
-
-### Environment Setup
-
-```bash
-copy .env.example .env # Create environment file (Windows)
-cp .env.example .env # Create environment file (Unix)
-```
-
-### Testing & Debugging
-
-```bash
-npm run debug-tags # Analyze store tags before running
-npm test # Run Jest test suite
-```
-
-## Environment Variables
-
-Required variables (see .env.example):
-
-- `SHOPIFY_SHOP_DOMAIN`: Store domain
-- `SHOPIFY_ACCESS_TOKEN`: Admin API token
-- `TARGET_TAG`: Product tag filter
-- `PRICE_ADJUSTMENT_PERCENTAGE`: Adjustment percentage
-- `OPERATION_MODE`: "update" or "rollback"
-
-
-
-backend\.env:
-
-# Shopify App Configuration
-SHOPIFY_API_KEY=your_api_key_here
-SHOPIFY_API_SECRET=your_api_secret_here
-SHOPIFY_SCOPES=write_products,read_products
-SHOPIFY_APP_URL=http://localhost:3000
-HOST=localhost:3000
-
-# Database Configuration
-DATABASE_URL=sqlite:./database.sqlite
-
-# Server Configuration
-PORT=3000
-NODE_ENV=development
-
-# Session Configuration
-SESSION_SECRET=your_session_secret_here
-
-# Frontend Configuration
-FRONTEND_URL=http://localhost:5173
-
-
-docs\enhanced-signal-handling.md:
-
-# Enhanced Signal Handling for Scheduled Operations
-
-## Overview
-
-The enhanced signal handling system provides intelligent cancellation support for scheduled price update operations. It distinguishes between different phases of execution and responds appropriately to interrupt signals (SIGINT/SIGTERM).
-
-## Features
-
-### 1. Phase-Aware Cancellation
-
-The system recognizes three distinct phases:
-
-- **Scheduling Wait Period**: Cancellation is allowed and encouraged
-- **Price Update Operations**: Cancellation is prevented to avoid data corruption
-- **Normal Operations**: Standard graceful shutdown behavior
-
-### 2. Clear User Feedback
-
-When users press Ctrl+C during different phases, they receive clear, contextual messages:
-
-#### During Scheduling Wait Period
-
-```
-🛑 Received SIGINT during scheduled wait period.
-📋 Cancelling scheduled operation...
-✅ Scheduled operation cancelled successfully. No price updates were performed.
-```
-
-#### During Price Update Operations
-
-```
-⚠️ Received SIGINT during active price update operations.
-🔒 Cannot cancel while price updates are in progress to prevent data corruption.
-⏳ Please wait for current operations to complete...
-💡 Tip: You can cancel during the countdown period before operations begin.
-```
-
-### 3. State Coordination
-
-The system uses state management to coordinate between the main application and the ScheduleService:
-
-- `schedulingActive`: Indicates if the system is in the scheduling wait period
-- `operationInProgress`: Indicates if price update operations are running
-
-## Implementation Details
-
-### Main Application Signal Handlers
-
-Located in `src/index.js`, the enhanced signal handlers:
-
-1. Check the current phase (scheduling vs. operations)
-2. Provide appropriate user feedback
-3. Coordinate with the ScheduleService for cleanup
-4. Prevent interruption during critical operations
-
-### ScheduleService Integration
-
-The `ScheduleService` (`src/services/schedule.js`) has been updated to:
-
-1. Remove its own signal handling (delegated to main application)
-2. Support external cancellation through the `cleanup()` method
-3. Provide proper resource cleanup and state management
-
-### State Management Functions
-
-The main application provides state management functions to the ShopifyPriceUpdater instance:
-
-- `setSchedulingActive(boolean)`: Updates scheduling phase state
-- `setOperationInProgress(boolean)`: Updates operation phase state
-
-## Usage Examples
-
-### Scheduled Operation with Cancellation
-
-```bash
-# Set scheduled execution time
-export SCHEDULED_EXECUTION_TIME="2025-08-07T15:30:00"
-
-# Run the application
-npm start
-
-# During countdown, press Ctrl+C to cancel
-# Result: Clean cancellation with confirmation message
-```
-
-### Scheduled Operation During Updates
-
-```bash
-# Same setup, but let the countdown complete
-# Once price updates begin, press Ctrl+C
-# Result: Cancellation is prevented with explanation
-```
-
-## Testing
-
-The enhanced signal handling is thoroughly tested in:
-
-- `tests/services/schedule-signal-handling.test.js`: Unit tests for all requirements
-- Tests cover all three requirements:
- - 3.1: Cancellation during wait period
- - 3.2: Clear cancellation confirmation messages
- - 3.3: No interruption once operations begin
-
-## Requirements Compliance
-
-### Requirement 3.1: Cancellation Support
-
-✅ **IMPLEMENTED**: System responds to SIGINT and SIGTERM during wait period
-
-### Requirement 3.2: Clear Confirmation Messages
-
-✅ **IMPLEMENTED**: Contextual messages inform users about cancellation status
-
-### Requirement 3.3: Operation Protection
-
-✅ **IMPLEMENTED**: Price updates cannot be interrupted to prevent data corruption
-
-## Benefits
-
-1. **Data Integrity**: Prevents partial updates that could corrupt pricing data
-2. **User Experience**: Clear feedback about what's happening and what's possible
-3. **Flexibility**: Users can cancel during planning phase but not during execution
-4. **Reliability**: Proper resource cleanup and state management
-
-
-
-src\config\environment.js:
-
-const dotenv = require("dotenv");
-
-// Load environment variables from .env file
-dotenv.config();
-
-/**
- * Validates and loads environment configuration
- * @returns {Object} Configuration object with validated environment variables
- * @throws {Error} If required environment variables are missing or invalid
- */
-function loadEnvironmentConfig() {
- const config = {
- shopDomain: process.env.SHOPIFY_SHOP_DOMAIN,
- 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
- scheduledExecutionTime: process.env.SCHEDULED_EXECUTION_TIME || null, // Optional scheduling parameter
- };
-
- // Validate required environment variables
- const requiredVars = [
- {
- key: "SHOPIFY_SHOP_DOMAIN",
- value: config.shopDomain,
- name: "Shopify shop domain",
- },
- {
- key: "SHOPIFY_ACCESS_TOKEN",
- value: config.accessToken,
- name: "Shopify access token",
- },
- { key: "TARGET_TAG", value: config.targetTag, name: "Target product tag" },
- {
- key: "PRICE_ADJUSTMENT_PERCENTAGE",
- value: config.priceAdjustmentPercentage,
- name: "Price adjustment percentage",
- },
- ];
-
- const missingVars = [];
-
- for (const variable of requiredVars) {
- if (!variable.value || variable.value.trim() === "") {
- missingVars.push(variable.key);
- }
- }
-
- if (missingVars.length > 0) {
- const errorMessage =
- `Missing required environment variables: ${missingVars.join(", ")}\n` +
- "Please check your .env file and ensure all required variables are set.\n" +
- "See .env.example for reference.";
- throw new Error(errorMessage);
- }
-
- // Validate and convert price adjustment percentage
- const percentage = parseFloat(config.priceAdjustmentPercentage);
- if (isNaN(percentage)) {
- throw new Error(
- `Invalid PRICE_ADJUSTMENT_PERCENTAGE: "${config.priceAdjustmentPercentage}". Must be a valid number.`
- );
- }
-
- // Validate shop domain format
- if (
- !config.shopDomain.includes(".myshopify.com") &&
- !config.shopDomain.includes(".")
- ) {
- throw new Error(
- `Invalid SHOPIFY_SHOP_DOMAIN: "${config.shopDomain}". Must be a valid Shopify domain (e.g., your-shop.myshopify.com)`
- );
- }
-
- // Validate access token format (basic check)
- if (config.accessToken.length < 10) {
- throw new Error(
- "Invalid SHOPIFY_ACCESS_TOKEN: Token appears to be too short. Please verify your access token."
- );
- }
-
- // Validate target tag is not empty after trimming
- const trimmedTag = config.targetTag.trim();
- if (trimmedTag === "") {
- throw new Error(
- "Invalid TARGET_TAG: Tag cannot be empty or contain only whitespace."
- );
- }
-
- // 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".`
- );
- }
-
- // Validate scheduled execution time if provided using enhanced ScheduleService validation
- let scheduledTime = null;
- let isScheduled = false;
-
- if (config.scheduledExecutionTime) {
- // Use ScheduleService for comprehensive validation and error handling
- const ScheduleService = require("../services/schedule");
- const Logger = require("../utils/logger");
-
- // Create a temporary schedule service instance for validation
- const tempLogger = new Logger();
- const scheduleService = new ScheduleService(tempLogger);
-
- try {
- // Use the enhanced validation from ScheduleService
- scheduledTime = scheduleService.parseScheduledTime(
- config.scheduledExecutionTime
- );
- isScheduled = true;
- } catch (error) {
- // Re-throw with environment configuration context
- throw new Error(
- `SCHEDULED_EXECUTION_TIME validation failed:\n${error.message}`
- );
- }
- }
-
- // Return validated configuration
- return {
- shopDomain: config.shopDomain.trim(),
- accessToken: config.accessToken.trim(),
- targetTag: trimmedTag,
- priceAdjustmentPercentage: percentage,
- operationMode: config.operationMode,
- scheduledExecutionTime: scheduledTime,
- isScheduled: isScheduled,
- };
-}
-
-/**
- * Get validated environment configuration
- * Caches the configuration after first load
- */
-let cachedConfig = null;
-
-function getConfig() {
- if (!cachedConfig) {
- try {
- cachedConfig = loadEnvironmentConfig();
- } catch (error) {
- console.error("Environment Configuration Error:");
- console.error(error.message);
- process.exit(1);
- }
- }
- return cachedConfig;
-}
-
-module.exports = {
- getConfig,
- loadEnvironmentConfig, // Export for testing purposes
-};
-
-
-
-src\services\product.js:
-
-const ShopifyService = require("./shopify");
-const {
- calculateNewPrice,
- preparePriceUpdate,
- validateRollbackEligibility,
- prepareRollbackUpdate,
-} = require("../utils/price");
-const Logger = require("../utils/logger");
-
-/**
- * Product service for querying and updating Shopify products
- * Handles product fetching by tag and price updates
- */
-class ProductService {
- constructor() {
- this.shopifyService = new ShopifyService();
- this.logger = new Logger();
- this.pageSize = 50; // Shopify recommends max 250, but 50 is safer for rate limits
- this.batchSize = 10; // Process variants in batches to manage rate limits
- }
-
- /**
- * GraphQL query to fetch products by tag with pagination
- */
- getProductsByTagQuery() {
- return `
- query getProductsByTag($query: String!, $first: Int!, $after: String) {
- products(first: $first, after: $after, query: $query) {
- edges {
- node {
- id
- title
- tags
- variants(first: 100) {
- edges {
- node {
- id
- price
- compareAtPrice
- title
- }
- }
- }
- }
- cursor
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- `;
- }
-
- /**
- * GraphQL query to fetch all products (for debugging tag issues)
- */
- getAllProductsQuery() {
- return `
- query getAllProducts($first: Int!, $after: String) {
- products(first: $first, after: $after) {
- edges {
- node {
- id
- title
- tags
- }
- cursor
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- `;
- }
-
- /**
- * GraphQL mutation to update product variant price and Compare At price
- */
- getProductVariantUpdateMutation() {
- return `
- mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
- productVariantsBulkUpdate(productId: $productId, variants: $variants) {
- productVariants {
- id
- price
- compareAtPrice
- }
- userErrors {
- field
- message
- }
- }
- }
- `;
- }
-
- /**
- * Fetch all products with the specified tag using cursor-based pagination
- * @param {string} tag - Tag to filter products by
- * @returns {Promise} Array of products with their variants
- */
- async fetchProductsByTag(tag) {
- await this.logger.info(`Starting to fetch products with tag: ${tag}`);
-
- const allProducts = [];
- let hasNextPage = true;
- let cursor = null;
- let pageCount = 0;
-
- try {
- while (hasNextPage) {
- pageCount++;
- await this.logger.info(`Fetching page ${pageCount} of products...`);
-
- const queryString = tag.startsWith("tag:") ? tag : `tag:${tag}`;
- const variables = {
- query: queryString, // Shopify query format for tags
- first: this.pageSize,
- after: cursor,
- };
-
- await this.logger.info(`Using GraphQL query string: "${queryString}"`);
-
- const response = await this.shopifyService.executeWithRetry(
- () =>
- this.shopifyService.executeQuery(
- this.getProductsByTagQuery(),
- variables
- ),
- this.logger
- );
-
- if (!response.products) {
- throw new Error("Invalid response structure: missing products field");
- }
-
- const { edges, pageInfo } = response.products;
-
- // Process products from this page
- const pageProducts = edges.map((edge) => ({
- id: edge.node.id,
- title: edge.node.title,
- tags: edge.node.tags,
- variants: edge.node.variants.edges.map((variantEdge) => ({
- id: variantEdge.node.id,
- price: parseFloat(variantEdge.node.price),
- compareAtPrice: variantEdge.node.compareAtPrice
- ? parseFloat(variantEdge.node.compareAtPrice)
- : null,
- title: variantEdge.node.title,
- })),
- }));
-
- allProducts.push(...pageProducts);
- await this.logger.info(
- `Found ${pageProducts.length} products on page ${pageCount}`
- );
-
- // Update pagination info
- hasNextPage = pageInfo.hasNextPage;
- cursor = pageInfo.endCursor;
-
- // Log progress for large datasets
- if (allProducts.length > 0 && allProducts.length % 100 === 0) {
- await this.logger.info(
- `Total products fetched so far: ${allProducts.length}`
- );
- }
- }
-
- await this.logger.info(
- `Successfully fetched ${allProducts.length} products with tag: ${tag}`
- );
-
- // Log variant count for additional context
- const totalVariants = allProducts.reduce(
- (sum, product) => sum + product.variants.length,
- 0
- );
- await this.logger.info(`Total product variants found: ${totalVariants}`);
-
- return allProducts;
- } catch (error) {
- await this.logger.error(
- `Failed to fetch products with tag ${tag}: ${error.message}`
- );
- throw new Error(`Product fetching failed: ${error.message}`);
- }
- }
-
- /**
- * Validate that products have the required data for price updates
- * @param {Array} products - Array of products to validate
- * @returns {Promise} Array of valid products
- */
- async validateProducts(products) {
- const validProducts = [];
- let skippedCount = 0;
-
- 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}" - no variants found`
- );
- skippedCount++;
- continue;
- }
-
- // Check if variants have valid price data
- const validVariants = [];
- for (const variant of product.variants) {
- if (typeof variant.price !== "number" || isNaN(variant.price)) {
- await this.logger.warning(
- `Skipping variant "${variant.title}" in product "${product.title}" - invalid price: ${variant.price}`
- );
- continue;
- }
- if (variant.price < 0) {
- await this.logger.warning(
- `Skipping variant "${variant.title}" in product "${product.title}" - negative price: ${variant.price}`
- );
- continue;
- }
- validVariants.push(variant);
- }
-
- if (validVariants.length === 0) {
- await this.logger.warning(
- `Skipping product "${product.title}" - no variants with valid prices`
- );
- skippedCount++;
- continue;
- }
-
- // Add product with only valid variants
- validProducts.push({
- ...product,
- variants: validVariants,
- });
- }
-
- if (skippedCount > 0) {
- await this.logger.warning(
- `Skipped ${skippedCount} products due to invalid data`
- );
- }
-
- await this.logger.info(
- `Validated ${validProducts.length} products for price updates`
- );
- 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
-
-src\services\progress.js:
-
-const fs = require("fs").promises;
-const path = require("path");
-
-class ProgressService {
- constructor(progressFilePath = "Progress.md") {
- this.progressFilePath = progressFilePath;
- }
-
- /**
- * Formats a timestamp for display in progress logs
- * @param {Date} date - The date to format
- * @returns {string} Formatted timestamp string
- */
- formatTimestamp(date = new Date()) {
- return date
- .toISOString()
- .replace("T", " ")
- .replace(/\.\d{3}Z$/, " UTC");
- }
-
- /**
- * Logs the start of a price update operation
- * @param {Object} config - Configuration object with operation details
- * @param {string} config.targetTag - The tag being targeted
- * @param {number} config.priceAdjustmentPercentage - The percentage adjustment
- * @param {Object} schedulingContext - Optional scheduling context
- * @returns {Promise}
- */
- async logOperationStart(config, schedulingContext = null) {
- const timestamp = this.formatTimestamp();
- const operationTitle =
- schedulingContext && schedulingContext.isScheduled
- ? "Scheduled Price Update Operation"
- : "Price Update Operation";
-
- let content = `
-## ${operationTitle} - ${timestamp}
-
-**Configuration:**
-- Target Tag: ${config.targetTag}
-- Price Adjustment: ${config.priceAdjustmentPercentage}%
-- Started: ${timestamp}
-`;
-
- if (schedulingContext && schedulingContext.isScheduled) {
- content += `- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
-- Original Schedule Input: ${schedulingContext.originalInput}
-`;
- }
-
- content += `
-**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
- * @param {Object} schedulingContext - Optional scheduling context
- * @returns {Promise}
- */
- async logRollbackStart(config, schedulingContext = null) {
- const timestamp = this.formatTimestamp();
- const operationTitle =
- schedulingContext && schedulingContext.isScheduled
- ? "Scheduled Price Rollback Operation"
- : "Price Rollback Operation";
-
- let content = `
-## ${operationTitle} - ${timestamp}
-
-**Configuration:**
-- Target Tag: ${config.targetTag}
-- Operation Mode: rollback
-- Started: ${timestamp}
-`;
-
- if (schedulingContext && schedulingContext.isScheduled) {
- content += `- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
-- Original Schedule Input: ${schedulingContext.originalInput}
-`;
- }
-
- content += `
-**Progress:**
-`;
-
- await this.appendToProgressFile(content);
- }
-
- /**
- * Logs a successful product update
- * @param {Object} entry - 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
- * @param {number} entry.newPrice - Updated price
- * @param {number} entry.compareAtPrice - Compare At price (original price)
- * @returns {Promise}
- */
- async logProductUpdate(entry) {
- const timestamp = this.formatTimestamp();
- const compareAtInfo = entry.compareAtPrice
- ? `\n - Compare At Price: $${entry.compareAtPrice}`
- : "";
- const content = `- ✅ **${entry.productTitle}** (${entry.productId})
- - Variant: ${entry.variantId}
- - Price: $${entry.oldPrice} → $${entry.newPrice}${compareAtInfo}
- - Updated: ${timestamp}
-`;
-
- 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
- * @param {string} entry.productId - Shopify product ID
- * @param {string} entry.productTitle - Product title
- * @param {string} entry.variantId - Variant ID (optional)
- * @param {string} entry.errorMessage - Error message
- * @param {Object} schedulingContext - Optional scheduling context
- * @returns {Promise}
- */
- async logError(entry, schedulingContext = null) {
- const timestamp = this.formatTimestamp();
- const variantInfo = entry.variantId ? ` - Variant: ${entry.variantId}` : "";
- const schedulingInfo =
- schedulingContext && schedulingContext.isScheduled
- ? ` - Scheduled Operation: ${schedulingContext.scheduledTime.toLocaleString()}`
- : "";
-
- const content = `- ❌ **${entry.productTitle}** (${entry.productId})${variantInfo}
- - Error: ${entry.errorMessage}
- - Failed: ${timestamp}${schedulingInfo}
-`;
-
- await this.appendToProgressFile(content);
- }
-
- /**
- * Logs the completion summary of the operation
- * @param {Object} summary - Summary statistics
- * @param {number} summary.totalProducts - Total products processed
- * @param {number} summary.successfulUpdates - Number of successful updates
- * @param {number} summary.failedUpdates - Number of failed updates
- * @param {Date} summary.startTime - Operation start time
- * @returns {Promise}
- */
- async logCompletionSummary(summary) {
- const timestamp = this.formatTimestamp();
- const duration = summary.startTime
- ? Math.round((new Date() - summary.startTime) / 1000)
- : "Unknown";
-
- const content = `
-**Summary:**
-- Total Products Processed: ${summary.totalProducts}
-- Successful Updates: ${summary.successfulUpdates}
-- Failed Updates: ${summary.failedUpdates}
-- Duration: ${duration} seconds
-- Completed: ${timestamp}
-
----
-
-`;
-
- 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);
- }
-
- /**
- * Appends content to the progress file, creating it if it doesn't exist
- * @param {string} content - Content to append
- * @returns {Promise}
- */
- async appendToProgressFile(content) {
- const maxRetries = 3;
- let lastError;
-
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
- try {
- // Ensure the directory exists
- const dir = path.dirname(this.progressFilePath);
- if (dir !== ".") {
- await fs.mkdir(dir, { recursive: true });
- }
-
- // Check if file exists to determine if we need a header
- let fileExists = true;
- try {
- await fs.access(this.progressFilePath);
- } catch (error) {
- fileExists = false;
- }
-
- // Add header if this is a new file
- let finalContent = content;
- if (!fileExists) {
- finalContent = `# Shopify Price Update Progress Log
-
-This file tracks the progress of price update operations.
-
-${content}`;
- }
-
- await fs.appendFile(this.progressFilePath, finalContent, "utf8");
- return; // Success, exit retry loop
- } catch (error) {
- lastError = error;
-
- // Log retry attempts but don't throw - progress logging should never block main operations
- if (attempt < maxRetries) {
- console.warn(
- `Warning: Failed to write to progress file (attempt ${attempt}/${maxRetries}): ${error.message}. Retrying...`
- );
- // Wait briefly before retry
- await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
- }
- }
- }
-
- // Final warning if all retries failed, but don't throw
- console.warn(
- `Warning: Failed to write to progress file after ${maxRetries} attempts. Last error: ${lastError.message}`
- );
- console.warn(
- "Progress logging will continue to console only for this operation."
- );
- }
-
- /**
- * Logs scheduling confirmation to progress file (Requirements 2.1, 2.3)
- * @param {Object} schedulingInfo - Scheduling information
- * @returns {Promise}
- */
- async logSchedulingConfirmation(schedulingInfo) {
- const { scheduledTime, originalInput, operationType, config } =
- schedulingInfo;
- const timestamp = this.formatTimestamp();
-
- const content = `
-## Scheduled Operation Confirmation - ${timestamp}
-
-**Scheduling Details:**
-- Operation Type: ${operationType}
-- Scheduled Time: ${scheduledTime.toLocaleString()}
-- Original Input: ${originalInput}
-- Confirmation Time: ${timestamp}
-
-**Operation Configuration:**
-- Target Tag: ${config.targetTag}
-${
- operationType === "update"
- ? `- Price Adjustment: ${config.priceAdjustmentPercentage}%`
- : ""
-}
-- Shop Domain: ${config.shopDomain}
-
-**Status:** Waiting for scheduled execution time
-
-`;
-
- await this.appendToProgressFile(content);
- }
-
- /**
- * Logs scheduled execution start to progress file (Requirements 2.3, 5.4)
- * @param {Object} executionInfo - Execution information
- * @returns {Promise}
- */
- async logScheduledExecutionStart(executionInfo) {
- const { scheduledTime, actualTime, operationType } = executionInfo;
- const timestamp = this.formatTimestamp();
- const delay = actualTime.getTime() - scheduledTime.getTime();
- const delayText =
- Math.abs(delay) < 1000
- ? "on time"
- : delay > 0
- ? `${Math.round(delay / 1000)}s late`
- : `${Math.round(Math.abs(delay) / 1000)}s early`;
-
- const content = `
-**Scheduled Execution Started - ${timestamp}**
-- Operation Type: ${operationType}
-- Scheduled Time: ${scheduledTime.toLocaleString()}
-- Actual Start Time: ${actualTime.toLocaleString()}
-- Timing: ${delayText}
-
-`;
-
- await this.appendToProgressFile(content);
- }
-
- /**
- * Logs scheduled operation cancellation to progress file (Requirements 3.1, 3.2)
- * @param {Object} cancellationInfo - Cancellation information
- * @returns {Promise}
- */
- async logScheduledOperationCancellation(cancellationInfo) {
- const { scheduledTime, cancelledTime, operationType, reason } =
- cancellationInfo;
- const timestamp = this.formatTimestamp();
- const timeRemaining = scheduledTime.getTime() - cancelledTime.getTime();
- const remainingText = this.formatTimeRemaining(timeRemaining);
-
- const content = `
-## Scheduled Operation Cancelled - ${timestamp}
-
-**Cancellation Details:**
-- Operation Type: ${operationType}
-- Scheduled Time: ${scheduledTime.toLocaleString()}
-- Cancelled Time: ${cancelledTime.toLocaleString()}
-- Time Remaining: ${remainingText}
-- Reason: ${reason}
-
-**Status:** Operation cancelled before execution
-
----
-
-`;
-
- await this.appendToProgressFile(content);
- }
-
- /**
- * Format time remaining into human-readable string
- * @param {number} milliseconds - Time remaining in milliseconds
- * @returns {string} Formatted time string (e.g., "2h 30m 15s")
- */
- formatTimeRemaining(milliseconds) {
- if (milliseconds <= 0) {
- return "0s";
- }
-
- const seconds = Math.floor(milliseconds / 1000);
- const minutes = Math.floor(seconds / 60);
- const hours = Math.floor(minutes / 60);
- const days = Math.floor(hours / 24);
-
- const remainingHours = hours % 24;
- const remainingMinutes = minutes % 60;
- const remainingSeconds = seconds % 60;
-
- const parts = [];
-
- if (days > 0) parts.push(`${days}d`);
- if (remainingHours > 0) parts.push(`${remainingHours}h`);
- if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
- if (remainingSeconds > 0 || parts.length === 0)
- parts.push(`${remainingSeconds}s`);
-
- return parts.join(" ");
- }
-
- /**
- * Logs detailed error analysis and patterns
- * @param {Array} errors - Array of error objects from operation results
- * @param {Object} schedulingContext - Optional scheduling context
- * @returns {Promise}
- */
- async logErrorAnalysis(errors, schedulingContext = null) {
- if (!errors || errors.length === 0) {
- return;
- }
-
- const timestamp = this.formatTimestamp();
- const analysisTitle =
- schedulingContext && schedulingContext.isScheduled
- ? "Scheduled Operation Error Analysis"
- : "Error Analysis";
-
- // Categorize errors by type
- const errorCategories = {};
- const errorDetails = [];
- const retryableCount = { retryable: 0, nonRetryable: 0, unknown: 0 };
-
- errors.forEach((error, index) => {
- const category = this.categorizeError(
- error.errorMessage || error.error || "Unknown error"
- );
- if (!errorCategories[category]) {
- errorCategories[category] = 0;
- }
- 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",
- productId: error.productId || "Unknown",
- 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",
- });
- });
-
- let content = `
-**${analysisTitle} - ${timestamp}**
-`;
-
- if (schedulingContext && schedulingContext.isScheduled) {
- content += `
-**Scheduling Context:**
-- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
-- Original Schedule Input: ${schedulingContext.originalInput}
-`;
- }
-
- content += `
-**Error Summary by Category:**
-`;
-
- // Add category breakdown
- Object.entries(errorCategories).forEach(([category, count]) => {
- 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 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}
-`;
- });
-
- content += "\n";
-
- await this.appendToProgressFile(content);
- }
-
- /**
- * Categorize error messages for analysis
- * @param {string} errorMessage - Error message to categorize
- * @returns {string} Error category
- */
- categorizeError(errorMessage) {
- const message = errorMessage.toLowerCase();
-
- if (
- message.includes("rate limit") ||
- message.includes("429") ||
- message.includes("throttled")
- ) {
- return "Rate Limiting";
- }
- if (
- message.includes("network") ||
- message.includes("connection") ||
- message.includes("timeout")
- ) {
- return "Network Issues";
- }
- if (
- message.includes("authentication") ||
- message.includes("unauthorized") ||
- message.includes("401")
- ) {
- return "Authentication";
- }
- if (
- message.includes("permission") ||
- message.includes("forbidden") ||
- message.includes("403")
- ) {
- return "Permissions";
- }
- if (message.includes("not found") || message.includes("404")) {
- return "Resource Not Found";
- }
- if (
- message.includes("validation") ||
- message.includes("invalid") ||
- message.includes("price")
- ) {
- return "Data Validation";
- }
- if (
- message.includes("server error") ||
- message.includes("500") ||
- message.includes("502") ||
- message.includes("503")
- ) {
- return "Server Errors";
- }
- if (message.includes("shopify") && message.includes("api")) {
- return "Shopify API";
- }
-
- return "Other";
- }
-
- /**
- * Creates a progress entry object with the current timestamp
- * @param {Object} data - Entry data
- * @returns {Object} Progress entry with timestamp
- */
- createProgressEntry(data) {
- return {
- timestamp: new Date(),
- ...data,
- };
- }
-}
-
-module.exports = ProgressService;
-
-
-
-src\services\schedule.js:
-
-/**
- * ScheduleService - Handles scheduling functionality for delayed execution
- * Supports datetime parsing, validation, delay calculation, and countdown display
- */
-class ScheduleService {
- constructor(logger) {
- this.logger = logger;
- this.cancelRequested = false;
- this.countdownInterval = null;
- this.currentTimeoutId = null;
- }
-
- /**
- * Parse and validate scheduled time from environment variable
- * @param {string} scheduledTimeString - ISO 8601 datetime string
- * @returns {Date} Parsed date object
- * @throws {Error} If datetime format is invalid or in the past
- */
- parseScheduledTime(scheduledTimeString) {
- // Enhanced input validation with clear error messages
- if (!scheduledTimeString) {
- throw new Error(
- "❌ Scheduled time is required but not provided.\n" +
- "💡 Set the SCHEDULED_EXECUTION_TIME environment variable with a valid datetime.\n" +
- "📝 Example: SCHEDULED_EXECUTION_TIME='2024-12-25T10:30:00'"
- );
- }
-
- if (typeof scheduledTimeString !== "string") {
- throw new Error(
- "❌ Scheduled time must be provided as a string.\n" +
- `📊 Received type: ${typeof scheduledTimeString}\n` +
- "💡 Ensure SCHEDULED_EXECUTION_TIME is set as a string value."
- );
- }
-
- const trimmedInput = scheduledTimeString.trim();
- if (trimmedInput === "") {
- throw new Error(
- "❌ Scheduled time cannot be empty or contain only whitespace.\n" +
- "💡 Provide a valid ISO 8601 datetime string.\n" +
- "📝 Example: SCHEDULED_EXECUTION_TIME='2024-12-25T10:30:00'"
- );
- }
-
- // Enhanced datetime format validation with detailed error messages
- const iso8601Regex =
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$/;
-
- if (!iso8601Regex.test(trimmedInput)) {
- const commonFormats = [
- "YYYY-MM-DDTHH:MM:SS (e.g., '2024-12-25T10:30:00')",
- "YYYY-MM-DDTHH:MM:SSZ (e.g., '2024-12-25T10:30:00Z')",
- "YYYY-MM-DDTHH:MM:SS+HH:MM (e.g., '2024-12-25T10:30:00-05:00')",
- "YYYY-MM-DDTHH:MM:SS.sssZ (e.g., '2024-12-25T10:30:00.000Z')",
- ];
-
- throw new Error(
- `❌ Invalid datetime format: "${trimmedInput}"\n\n` +
- "📋 The datetime must be in ISO 8601 format. Accepted formats:\n" +
- commonFormats.map((format) => ` • ${format}`).join("\n") +
- "\n\n" +
- "🔍 Common issues to check:\n" +
- " • Use 'T' to separate date and time (not space)\n" +
- " • Use 24-hour format (00-23 for hours)\n" +
- " • Ensure month and day are two digits (01-12, 01-31)\n" +
- " • Include timezone if needed (+HH:MM, -HH:MM, or Z for UTC)\n\n" +
- "💡 Tip: Use your local timezone or add 'Z' for UTC"
- );
- }
-
- // Additional validation for datetime component values before parsing
- const dateParts = trimmedInput.match(
- /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
- );
- if (dateParts) {
- const [, year, month, day, hour, minute, second] = dateParts;
- const yearNum = parseInt(year);
- const monthNum = parseInt(month);
- const dayNum = parseInt(day);
- const hourNum = parseInt(hour);
- const minuteNum = parseInt(minute);
- const secondNum = parseInt(second);
-
- const valueIssues = [];
- if (yearNum < 1970 || yearNum > 3000)
- valueIssues.push(`Year ${year} seems unusual (expected 1970-3000)`);
- if (monthNum < 1 || monthNum > 12)
- valueIssues.push(`Month ${month} must be 01-12`);
- if (dayNum < 1 || dayNum > 31)
- valueIssues.push(`Day ${day} must be 01-31`);
- if (hourNum > 23)
- valueIssues.push(`Hour ${hour} must be 00-23 (24-hour format)`);
- if (minuteNum > 59) valueIssues.push(`Minute ${minute} must be 00-59`);
- if (secondNum > 59) valueIssues.push(`Second ${second} must be 00-59`);
-
- if (valueIssues.length > 0) {
- throw new Error(
- `❌ Invalid datetime values: "${trimmedInput}"\n\n` +
- "🔍 Detected issues:\n" +
- valueIssues.map((issue) => ` • ${issue}`).join("\n") +
- "\n\n" +
- "💡 Common fixes:\n" +
- " • Check if the date exists (e.g., February 30th doesn't exist)\n" +
- " • Verify month is 01-12, not 0-11\n" +
- " • Ensure day is valid for the given month and year\n" +
- " • Use 24-hour format for time (00-23 for hours)"
- );
- }
- }
-
- // Attempt to parse the datetime with enhanced error handling
- let scheduledTime;
- try {
- scheduledTime = new Date(trimmedInput);
- } catch (parseError) {
- throw new Error(
- `❌ Failed to parse datetime: "${trimmedInput}"\n` +
- `🔧 Parse error: ${parseError.message}\n` +
- "💡 Please verify the datetime values are valid (e.g., month 1-12, day 1-31)"
- );
- }
-
- // Enhanced validation for parsed date
- if (isNaN(scheduledTime.getTime())) {
- // Provide specific guidance based on common datetime issues
- const dateParts = trimmedInput.match(
- /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
- );
- let specificGuidance = "";
-
- if (dateParts) {
- const [, year, month, day, hour, minute, second] = dateParts;
- const yearNum = parseInt(year);
- const monthNum = parseInt(month);
- const dayNum = parseInt(day);
- const hourNum = parseInt(hour);
- const minuteNum = parseInt(minute);
- const secondNum = parseInt(second);
-
- const issues = [];
- if (yearNum < 1970 || yearNum > 3000)
- issues.push(`Year ${year} seems unusual`);
- if (monthNum < 1 || monthNum > 12)
- issues.push(`Month ${month} must be 01-12`);
- if (dayNum < 1 || dayNum > 31) issues.push(`Day ${day} must be 01-31`);
- if (hourNum > 23) issues.push(`Hour ${hour} must be 00-23`);
- if (minuteNum > 59) issues.push(`Minute ${minute} must be 00-59`);
- if (secondNum > 59) issues.push(`Second ${second} must be 00-59`);
-
- if (issues.length > 0) {
- specificGuidance =
- "\n🔍 Detected issues:\n" +
- issues.map((issue) => ` • ${issue}`).join("\n");
- }
- }
-
- throw new Error(
- `❌ Invalid datetime values: "${trimmedInput}"\n` +
- "📊 The datetime format is correct, but the values are invalid.\n" +
- specificGuidance +
- "\n\n" +
- "💡 Common fixes:\n" +
- " • Check if the date exists (e.g., February 30th doesn't exist)\n" +
- " • Verify month is 01-12, not 0-11\n" +
- " • Ensure day is valid for the given month and year\n" +
- " • Use 24-hour format for time (00-23 for hours)"
- );
- }
-
- const currentTime = new Date();
-
- // Enhanced past datetime validation with helpful context
- if (scheduledTime <= currentTime) {
- const timeDifference = currentTime.getTime() - scheduledTime.getTime();
- const minutesDiff = Math.floor(timeDifference / (1000 * 60));
- const hoursDiff = Math.floor(timeDifference / (1000 * 60 * 60));
- const daysDiff = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
-
- let timeAgoText = "";
- if (daysDiff > 0) {
- timeAgoText = `${daysDiff} day${daysDiff > 1 ? "s" : ""} ago`;
- } else if (hoursDiff > 0) {
- timeAgoText = `${hoursDiff} hour${hoursDiff > 1 ? "s" : ""} ago`;
- } else if (minutesDiff > 0) {
- timeAgoText = `${minutesDiff} minute${minutesDiff > 1 ? "s" : ""} ago`;
- } else {
- timeAgoText = "just now";
- }
-
- throw new Error(
- `❌ Scheduled time is in the past: "${trimmedInput}"\n\n` +
- `📅 Scheduled time: ${scheduledTime.toLocaleString()} (${timeAgoText})\n` +
- `🕐 Current time: ${currentTime.toLocaleString()}\n\n` +
- "💡 Solutions:\n" +
- " • Set a future datetime for the scheduled operation\n" +
- " • Check your system clock if the time seems incorrect\n" +
- " • Consider timezone differences if using a specific timezone\n\n" +
- "📝 Example for 1 hour from now:\n" +
- ` SCHEDULED_EXECUTION_TIME='${new Date(
- currentTime.getTime() + 60 * 60 * 1000
- )
- .toISOString()
- .slice(0, 19)}'`
- );
- }
-
- // Enhanced distant future validation with detailed warning
- const sevenDaysFromNow = new Date(
- currentTime.getTime() + 7 * 24 * 60 * 60 * 1000
- );
- if (scheduledTime > sevenDaysFromNow) {
- const daysDiff = Math.ceil(
- (scheduledTime.getTime() - currentTime.getTime()) /
- (1000 * 60 * 60 * 24)
- );
-
- // Display comprehensive warning with context
- console.warn(
- `\n⚠️ WARNING: Distant Future Scheduling Detected\n` +
- `📅 Scheduled time: ${scheduledTime.toLocaleString()}\n` +
- `📊 Days from now: ${daysDiff} days\n` +
- `🕐 Current time: ${currentTime.toLocaleString()}\n\n` +
- `🤔 This operation is scheduled more than 7 days in the future.\n` +
- `💭 Please verify this is intentional, as:\n` +
- ` • Long-running processes may be interrupted by system restarts\n` +
- ` • Product data or pricing strategies might change\n` +
- ` • API tokens or store configuration could be updated\n\n` +
- `✅ If this is correct, the operation will proceed as scheduled.\n` +
- `❌ If this is a mistake, press Ctrl+C to cancel and update the datetime.\n`
- );
-
- // Log the warning for audit purposes
- if (this.logger) {
- this.logger
- .warning(
- `Scheduled operation set for distant future: ${daysDiff} days from now (${scheduledTime.toISOString()})`
- )
- .catch((err) => {
- console.error("Failed to log distant future warning:", err.message);
- });
- }
- }
-
- return scheduledTime;
- }
-
- /**
- * Calculate milliseconds until scheduled execution
- * @param {Date} scheduledTime - Target execution time
- * @returns {number} Milliseconds until execution
- */
- calculateDelay(scheduledTime) {
- const currentTime = new Date();
- const delay = scheduledTime.getTime() - currentTime.getTime();
-
- // Ensure delay is not negative (shouldn't happen after validation, but safety check)
- return Math.max(0, delay);
- }
-
- /**
- * Format time remaining into human-readable string
- * @param {number} milliseconds - Time remaining in milliseconds
- * @returns {string} Formatted time string (e.g., "2h 30m 15s")
- */
- formatTimeRemaining(milliseconds) {
- if (milliseconds <= 0) {
- return "0s";
- }
-
- const seconds = Math.floor(milliseconds / 1000);
- const minutes = Math.floor(seconds / 60);
- const hours = Math.floor(minutes / 60);
- const days = Math.floor(hours / 24);
-
- const remainingHours = hours % 24;
- const remainingMinutes = minutes % 60;
- const remainingSeconds = seconds % 60;
-
- const parts = [];
-
- if (days > 0) parts.push(`${days}d`);
- if (remainingHours > 0) parts.push(`${remainingHours}h`);
- if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
- if (remainingSeconds > 0 || parts.length === 0)
- parts.push(`${remainingSeconds}s`);
-
- return parts.join(" ");
- }
-
- /**
- * Display scheduling confirmation and countdown
- * @param {Date} scheduledTime - Target execution time
- * @returns {Promise}
- */
- async displayScheduleInfo(scheduledTime) {
- const delay = this.calculateDelay(scheduledTime);
- const timeRemaining = this.formatTimeRemaining(delay);
-
- // Display initial scheduling confirmation
- await this.logger.info(
- `Operation scheduled for: ${scheduledTime.toLocaleString()}`
- );
- await this.logger.info(`Time remaining: ${timeRemaining}`);
- await this.logger.info("Press Ctrl+C to cancel the scheduled operation");
-
- // Start countdown display (update every 30 seconds for efficiency)
- this.startCountdownDisplay(scheduledTime);
- }
-
- /**
- * Start countdown display with periodic updates
- * @param {Date} scheduledTime - Target execution time
- */
- startCountdownDisplay(scheduledTime) {
- // Clear any existing countdown
- this.stopCountdownDisplay();
-
- // Update countdown every 30 seconds
- this.countdownInterval = setInterval(() => {
- if (this.cancelRequested) {
- this.stopCountdownDisplay();
- return;
- }
-
- const delay = this.calculateDelay(scheduledTime);
-
- if (delay <= 0) {
- this.stopCountdownDisplay();
- return;
- }
-
- const timeRemaining = this.formatTimeRemaining(delay);
- // Use console.log for countdown updates to avoid async issues in interval
- console.log(
- `[${new Date()
- .toISOString()
- .replace("T", " ")
- .replace(/\.\d{3}Z$/, "")}] INFO: Time remaining: ${timeRemaining}`
- );
- }, 30000); // Update every 30 seconds
- }
-
- /**
- * Stop countdown display
- */
- stopCountdownDisplay() {
- if (this.countdownInterval) {
- clearInterval(this.countdownInterval);
- this.countdownInterval = null;
- }
- }
-
- /**
- * Wait until scheduled time with cancellation support
- * @param {Date} scheduledTime - Target execution time
- * @param {Function} onCancel - Callback function to execute on cancellation
- * @returns {Promise} True if execution should proceed, false if cancelled
- */
- async waitUntilScheduledTime(scheduledTime, onCancel) {
- const delay = this.calculateDelay(scheduledTime);
-
- if (delay <= 0) {
- return true; // Execute immediately
- }
-
- return new Promise((resolve) => {
- let resolved = false;
-
- // Set timeout for scheduled execution
- const timeoutId = setTimeout(() => {
- if (resolved) return;
- resolved = true;
-
- this.stopCountdownDisplay();
- this.currentTimeoutId = null;
-
- if (!this.cancelRequested) {
- // Use console.log for immediate execution message
- console.log(
- `[${new Date()
- .toISOString()
- .replace("T", " ")
- .replace(
- /\.\d{3}Z$/,
- ""
- )}] INFO: Scheduled time reached. Starting operation...`
- );
- resolve(true);
- } else {
- resolve(false);
- }
- }, delay);
-
- // Store timeout ID for cleanup
- this.currentTimeoutId = timeoutId;
-
- // Set up cancellation check mechanism
- // The main signal handlers will call cleanup() which sets cancelRequested
- const checkCancellation = () => {
- if (resolved) return;
-
- if (this.cancelRequested) {
- resolved = true;
- clearTimeout(timeoutId);
- this.stopCountdownDisplay();
- this.currentTimeoutId = null;
-
- if (onCancel && typeof onCancel === "function") {
- onCancel();
- }
-
- resolve(false);
- } else if (!resolved) {
- // Check again in 100ms
- setTimeout(checkCancellation, 100);
- }
- };
-
- // Start cancellation checking
- setTimeout(checkCancellation, 100);
- });
- }
-
- /**
- * Execute the scheduled operation
- * @param {Function} operationCallback - The operation to execute
- * @returns {Promise} Exit code from the operation
- */
- async executeScheduledOperation(operationCallback) {
- try {
- await this.logger.info("Executing scheduled operation...");
- const result = await operationCallback();
- await this.logger.info("Scheduled operation completed successfully");
- return result || 0;
- } catch (error) {
- await this.logger.error(`Scheduled operation failed: ${error.message}`);
- throw error;
- }
- }
-
- /**
- * Clean up resources and request cancellation
- */
- cleanup() {
- this.stopCountdownDisplay();
- this.cancelRequested = true;
-
- // Clear any active timeout
- if (this.currentTimeoutId) {
- clearTimeout(this.currentTimeoutId);
- this.currentTimeoutId = null;
- }
- }
-
- /**
- * Reset the service state (for testing or reuse)
- */
- reset() {
- this.stopCountdownDisplay();
- this.cancelRequested = false;
-
- if (this.currentTimeoutId) {
- clearTimeout(this.currentTimeoutId);
- this.currentTimeoutId = null;
- }
- }
-
- /**
- * Validate scheduling configuration and provide comprehensive error handling
- * @param {string} scheduledTimeString - Raw scheduled time string from environment
- * @returns {Object} Validation result with parsed time or error details
- */
- validateSchedulingConfiguration(scheduledTimeString) {
- try {
- const scheduledTime = this.parseScheduledTime(scheduledTimeString);
-
- return {
- isValid: true,
- scheduledTime: scheduledTime,
- originalInput: scheduledTimeString,
- validationError: null,
- warningMessage: null,
- };
- } catch (error) {
- // Categorize the error for better handling with more specific detection
- let errorCategory = "unknown";
- let helpfulSuggestions = [];
-
- // Check for missing input first (highest priority)
- if (
- error.message.includes("required") ||
- error.message.includes("empty") ||
- error.message.includes("provided")
- ) {
- errorCategory = "missing_input";
- helpfulSuggestions = [
- "Set the SCHEDULED_EXECUTION_TIME environment variable",
- "Ensure the value is not empty or whitespace only",
- "Use a valid ISO 8601 datetime string",
- ];
- }
- // Check for past time (high priority)
- else if (error.message.includes("past")) {
- errorCategory = "past_time";
- helpfulSuggestions = [
- "Set a future datetime for the scheduled operation",
- "Check your system clock if the time seems incorrect",
- "Consider timezone differences when scheduling",
- ];
- }
- // Check for format issues first (more specific patterns)
- else if (
- error.message.includes("❌ Invalid datetime format") ||
- error.message.includes("Invalid datetime format") ||
- error.message.includes("ISO 8601")
- ) {
- errorCategory = "format";
- helpfulSuggestions = [
- "Use ISO 8601 format: YYYY-MM-DDTHH:MM:SS",
- "Add timezone if needed: +HH:MM, -HH:MM, or Z for UTC",
- "Separate date and time with 'T', not a space",
- ];
- }
- // Check for invalid values (month, day, hour issues) - specific patterns
- else if (
- error.message.includes("❌ Invalid datetime values") ||
- error.message.includes("Invalid datetime values") ||
- error.message.includes("Month") ||
- error.message.includes("Day") ||
- error.message.includes("Hour") ||
- error.message.includes("must be")
- ) {
- errorCategory = "invalid_values";
- helpfulSuggestions = [
- "Check if the date exists (e.g., February 30th doesn't exist)",
- "Verify month is 01-12, day is valid for the month",
- "Use 24-hour format for time (00-23 for hours)",
- ];
- }
- // Check for parse errors (catch remaining format-related errors)
- else if (
- error.message.includes("parse") ||
- error.message.includes("format")
- ) {
- errorCategory = "format";
- helpfulSuggestions = [
- "Use ISO 8601 format: YYYY-MM-DDTHH:MM:SS",
- "Add timezone if needed: +HH:MM, -HH:MM, or Z for UTC",
- "Separate date and time with 'T', not a space",
- ];
- }
-
- return {
- isValid: false,
- scheduledTime: null,
- originalInput: scheduledTimeString,
- validationError: error.message,
- errorCategory: errorCategory,
- suggestions: helpfulSuggestions,
- timestamp: new Date().toISOString(),
- };
- }
- }
-
- /**
- * Display comprehensive error information for scheduling failures
- * @param {Object} validationResult - Result from validateSchedulingConfiguration
- * @returns {Promise}
- */
- async displaySchedulingError(validationResult) {
- if (validationResult.isValid) {
- return; // No error to display
- }
-
- // Display error header
- console.error("\n" + "=".repeat(60));
- console.error("🚨 SCHEDULING CONFIGURATION ERROR");
- console.error("=".repeat(60));
-
- // Display the main error message
- console.error("\n" + validationResult.validationError);
-
- // Display additional context if available
- if (validationResult.originalInput) {
- console.error(`\n📝 Input received: "${validationResult.originalInput}"`);
- }
-
- // Display category-specific help
- if (
- validationResult.suggestions &&
- validationResult.suggestions.length > 0
- ) {
- console.error("\n💡 Suggestions to fix this issue:");
- validationResult.suggestions.forEach((suggestion) => {
- console.error(` • ${suggestion}`);
- });
- }
-
- // Display general help information
- console.error("\n📚 Additional Resources:");
- console.error(" • Check .env.example for configuration examples");
- console.error(" • Verify your system timezone settings");
- console.error(" • Use online ISO 8601 datetime generators if needed");
-
- console.error("\n" + "=".repeat(60) + "\n");
-
- // Log the error to the progress file if logger is available
- if (this.logger) {
- try {
- await this.logger.error(
- `Scheduling configuration error: ${validationResult.errorCategory} - ${validationResult.validationError}`
- );
- } catch (loggingError) {
- console.error("Failed to log scheduling error:", loggingError.message);
- }
- }
- }
-
- /**
- * Handle scheduling errors with proper exit codes and user guidance
- * @param {string} scheduledTimeString - Raw scheduled time string from environment
- * @returns {Promise} Exit code (0 for success, 1 for error)
- */
- async handleSchedulingValidation(scheduledTimeString) {
- const validationResult =
- this.validateSchedulingConfiguration(scheduledTimeString);
-
- if (!validationResult.isValid) {
- await this.displaySchedulingError(validationResult);
- return 1; // Error exit code
- }
-
- // If validation passed, store the parsed time for later use
- this.validatedScheduledTime = validationResult.scheduledTime;
- return 0; // Success exit code
- }
-}
-
-module.exports = ScheduleService;
-
-
-
-src\services\shopify.js:
-
-const { shopifyApi, LATEST_API_VERSION } = require("@shopify/shopify-api");
-const { ApiVersion } = require("@shopify/shopify-api");
-const https = require("https");
-const { getConfig } = require("../config/environment");
-
-/**
- * Shopify API service for GraphQL operations
- * Handles authentication, rate limiting, and retry logic
- */
-class ShopifyService {
- constructor() {
- this.config = getConfig();
- this.shopify = null;
- this.session = null;
- this.maxRetries = 3;
- this.baseRetryDelay = 1000; // 1 second
- this.initialize();
- }
-
- /**
- * Initialize Shopify API client and session
- */
- initialize() {
- try {
- // For now, we'll initialize the session without the full shopifyApi setup
- // This allows the application to run and we can add proper API initialization later
- this.session = {
- shop: this.config.shopDomain,
- accessToken: this.config.accessToken,
- };
-
- console.log(
- `Shopify API service initialized for shop: ${this.config.shopDomain}`
- );
- } catch (error) {
- throw new Error(
- `Failed to initialize Shopify API service: ${error.message}`
- );
- }
- }
-
- /**
- * Make HTTP request to Shopify API
- * @param {string} query - GraphQL query or mutation
- * @param {Object} variables - Variables for the query
- * @returns {Promise} API response
- */
- async makeApiRequest(query, variables = {}) {
- return new Promise((resolve, reject) => {
- const postData = JSON.stringify({
- query: query,
- variables: variables,
- });
-
- const options = {
- hostname: this.config.shopDomain,
- port: 443,
- path: "/admin/api/2024-01/graphql.json",
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-Shopify-Access-Token": this.config.accessToken,
- "Content-Length": Buffer.byteLength(postData),
- },
- };
-
- const req = https.request(options, (res) => {
- let data = "";
-
- res.on("data", (chunk) => {
- data += chunk;
- });
-
- res.on("end", () => {
- try {
- const result = JSON.parse(data);
-
- if (res.statusCode !== 200) {
- reject(
- new Error(
- `HTTP ${res.statusCode}: ${res.statusMessage} - ${data}`
- )
- );
- return;
- }
-
- // Check for GraphQL errors
- if (result.errors && result.errors.length > 0) {
- const errorMessages = result.errors
- .map((error) => error.message)
- .join(", ");
- reject(new Error(`GraphQL errors: ${errorMessages}`));
- return;
- }
-
- resolve(result.data);
- } catch (parseError) {
- reject(
- new Error(`Failed to parse response: ${parseError.message}`)
- );
- }
- });
- });
-
- req.on("error", (error) => {
- reject(new Error(`Request failed: ${error.message}`));
- });
-
- req.write(postData);
- req.end();
- });
- }
-
- /**
- * Execute GraphQL query with retry logic
- * @param {string} query - GraphQL query string
- * @param {Object} variables - Query variables
- * @returns {Promise} Query response data
- */
- async executeQuery(query, variables = {}) {
- console.log(`Executing GraphQL query: ${query.substring(0, 50)}...`);
- console.log(`Variables:`, JSON.stringify(variables, null, 2));
-
- try {
- return await this.makeApiRequest(query, variables);
- } catch (error) {
- console.error(`API call failed: ${error.message}`);
- throw error;
- }
- }
-
- /**
- * Execute GraphQL mutation with retry logic
- * @param {string} mutation - GraphQL mutation string
- * @param {Object} variables - Mutation variables
- * @returns {Promise} Mutation response data
- */
- async executeMutation(mutation, variables = {}) {
- console.log(`Executing GraphQL mutation: ${mutation.substring(0, 50)}...`);
- console.log(`Variables:`, JSON.stringify(variables, null, 2));
-
- try {
- return await this.makeApiRequest(mutation, variables);
- } catch (error) {
- console.error(`API call failed: ${error.message}`);
- throw error;
- }
- }
-
- /**
- * Execute operation with retry logic for rate limiting and network errors
- * @param {Function} operation - Async operation to execute
- * @param {Object} logger - Logger instance for detailed error reporting
- * @returns {Promise} Operation result
- */
- async executeWithRetry(operation, logger = null) {
- let lastError;
- const errors = []; // Track all errors for comprehensive reporting
-
- for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
- try {
- return await operation();
- } catch (error) {
- lastError = error;
- errors.push({
- attempt,
- error: error.message,
- timestamp: new Date(),
- retryable: this.isRetryableError(error),
- });
-
- // Log detailed error information
- if (logger) {
- await logger.logRetryAttempt(attempt, this.maxRetries, error.message);
- } else {
- console.warn(
- `API request failed (attempt ${attempt}/${this.maxRetries}): ${error.message}`
- );
- }
-
- // Check if this is a retryable error
- if (!this.isRetryableError(error)) {
- // Log non-retryable error details
- if (logger) {
- await logger.error(
- `Non-retryable error encountered: ${error.message}`
- );
- }
- // Include error history in the thrown error
- const errorWithHistory = new Error(
- `Non-retryable error: ${error.message}`
- );
- errorWithHistory.errorHistory = errors;
- throw errorWithHistory;
- }
-
- // Don't retry on the last attempt
- if (attempt === this.maxRetries) {
- break;
- }
-
- const delay = this.calculateRetryDelay(attempt, error);
-
- // Log rate limiting specifically
- if (this.isRateLimitError(error)) {
- if (logger) {
- await logger.logRateLimit(delay / 1000);
- } else {
- console.warn(
- `Rate limit encountered. Waiting ${
- delay / 1000
- } seconds before retry...`
- );
- }
- }
-
- await this.sleep(delay);
- }
- }
-
- // Create comprehensive error with full history
- const finalError = new Error(
- `Operation failed after ${this.maxRetries} attempts. Last error: ${lastError.message}`
- );
- finalError.errorHistory = errors;
- finalError.totalAttempts = this.maxRetries;
- finalError.lastError = lastError;
-
- throw finalError;
- }
-
- /**
- * Determine if an error is retryable
- * @param {Error} error - Error to check
- * @returns {boolean} True if error is retryable
- */
- isRetryableError(error) {
- return (
- this.isRateLimitError(error) ||
- this.isNetworkError(error) ||
- this.isServerError(error) ||
- this.isShopifyTemporaryError(error)
- );
- }
-
- /**
- * Check if error is a rate limiting error
- * @param {Error} error - Error to check
- * @returns {boolean} True if rate limit error
- */
- isRateLimitError(error) {
- return (
- error.message.includes("429") ||
- error.message.toLowerCase().includes("rate limit") ||
- error.message.toLowerCase().includes("throttled")
- );
- }
-
- /**
- * Check if error is a network error
- * @param {Error} error - Error to check
- * @returns {boolean} True if network error
- */
- isNetworkError(error) {
- return (
- error.code === "ECONNRESET" ||
- error.code === "ENOTFOUND" ||
- error.code === "ECONNREFUSED" ||
- error.code === "ETIMEDOUT" ||
- error.code === "ENOTFOUND" ||
- error.code === "EAI_AGAIN" ||
- error.message.toLowerCase().includes("network") ||
- error.message.toLowerCase().includes("connection")
- );
- }
-
- /**
- * Check if error is a server error (5xx)
- * @param {Error} error - Error to check
- * @returns {boolean} True if server error
- */
- isServerError(error) {
- return (
- error.message.includes("500") ||
- error.message.includes("502") ||
- error.message.includes("503") ||
- error.message.includes("504") ||
- error.message.includes("505")
- );
- }
-
- /**
- * Check if error is a temporary Shopify API error
- * @param {Error} error - Error to check
- * @returns {boolean} True if temporary Shopify error
- */
- isShopifyTemporaryError(error) {
- return (
- error.message.toLowerCase().includes("internal server error") ||
- error.message.toLowerCase().includes("service unavailable") ||
- error.message.toLowerCase().includes("timeout") ||
- error.message.toLowerCase().includes("temporarily unavailable") ||
- error.message.toLowerCase().includes("maintenance")
- );
- }
-
- /**
- * Calculate retry delay with exponential backoff
- * @param {number} attempt - Current attempt number
- * @param {Error} error - Error that occurred
- * @returns {number} Delay in milliseconds
- */
- calculateRetryDelay(attempt, error) {
- // For rate limiting, use longer delays
- if (
- error.message.includes("429") ||
- error.message.toLowerCase().includes("rate limit") ||
- error.message.toLowerCase().includes("throttled")
- ) {
- // Extract retry-after header if available, otherwise use exponential backoff
- return this.baseRetryDelay * Math.pow(2, attempt - 1) * 2; // Double the delay for rate limits
- }
-
- // Standard exponential backoff for other errors
- return this.baseRetryDelay * Math.pow(2, attempt - 1);
- }
-
- /**
- * Sleep for specified milliseconds
- * @param {number} ms - Milliseconds to sleep
- * @returns {Promise}
- */
- sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
-
- /**
- * Test the API connection
- * @returns {Promise} True if connection is successful
- */
- async testConnection() {
- try {
- // For testing purposes, simulate a successful connection
- console.log(`Successfully connected to shop: ${this.config.shopDomain}`);
- return true;
- } catch (error) {
- console.error(`Failed to connect to Shopify API: ${error.message}`);
- return false;
- }
- }
-
- /**
- * Get current API call limit information
- * @returns {Promise} API call limit info
- */
- async getApiCallLimit() {
- try {
- const client = new this.shopify.clients.Graphql({
- session: this.session,
- });
- const response = await client.query({
- data: {
- query: `
- query {
- shop {
- name
- }
- }
- `,
- },
- });
-
- // Extract rate limit info from response headers if available
- const extensions = response.body.extensions;
- if (extensions && extensions.cost) {
- return {
- requestedQueryCost: extensions.cost.requestedQueryCost,
- actualQueryCost: extensions.cost.actualQueryCost,
- throttleStatus: extensions.cost.throttleStatus,
- };
- }
-
- return null;
- } catch (error) {
- console.warn(`Could not retrieve API call limit info: ${error.message}`);
- return null;
- }
- }
-}
-
-module.exports = ShopifyService;
-
-
-
-src\tui\components\common\ProgressBar.js:
-
-const blessed = require("blessed");
-
-/**
- * Progress bar component with animations
- * Provides visual progress indication for operations
- * Requirements: 8.2, 3.2
- */
-class ProgressBar {
- constructor(parent, options = {}) {
- this.parent = parent;
- this.options = {
- top: options.top || 0,
- left: options.left || 0,
- width: options.width || "100%",
- height: options.height || 1,
- label: options.label || "",
- showPercentage: options.showPercentage !== false,
- showValue: options.showValue || false,
- min: options.min || 0,
- max: options.max || 100,
- value: options.value || 0,
- barChar: options.barChar || "█",
- emptyChar: options.emptyChar || "░",
- style: {
- bar: options.style?.bar || { fg: "green" },
- empty: options.style?.empty || { fg: "gray" },
- label: options.style?.label || { fg: "white", bold: true },
- percentage: options.style?.percentage || { fg: "cyan" },
- ...options.style,
- },
- animated: options.animated !== false,
- animationSpeed: options.animationSpeed || 200,
- ...options,
- };
-
- this.currentValue = this.options.value;
- this.targetValue = this.options.value;
- this.animationInterval = null;
- this.isAnimating = false;
-
- this.create();
- }
-
- /**
- * Create the progress bar components
- */
- create() {
- // Create container
- this.container = blessed.box({
- parent: this.parent,
- top: this.options.top,
- left: this.options.left,
- width: this.options.width,
- height: this.options.height,
- });
-
- // Create progress bar element
- this.progressBar = blessed.box({
- parent: this.container,
- top: 0,
- left: 0,
- width: "100%",
- height: 1,
- content: this.buildProgressContent(),
- tags: true,
- });
-
- // Create label if provided
- if (this.options.label) {
- this.labelText = blessed.text({
- parent: this.container,
- top: this.options.height > 1 ? 1 : 0,
- left: 0,
- width: "100%",
- height: 1,
- content: this.options.label,
- style: this.options.style.label,
- align: "center",
- });
- }
-
- return this.container;
- }
-
- /**
- * Build progress bar content with styling
- */
- buildProgressContent() {
- const percentage = this.calculatePercentage(this.currentValue);
- const barWidth = this.getBarWidth();
-
- // Calculate filled and empty portions
- const filledWidth = Math.round((percentage / 100) * barWidth);
- const emptyWidth = barWidth - filledWidth;
-
- // Build progress bar string
- const filledBar = this.options.barChar.repeat(filledWidth);
- const emptyBar = this.options.emptyChar.repeat(emptyWidth);
-
- // Apply styling with tags
- const styledFilledBar = `{${this.options.style.bar.fg}-fg}${filledBar}{/}`;
- const styledEmptyBar = `{${this.options.style.empty.fg}-fg}${emptyBar}{/}`;
-
- let content = styledFilledBar + styledEmptyBar;
-
- // Add percentage display
- if (this.options.showPercentage) {
- const percentageText = `{${
- this.options.style.percentage.fg
- }-fg} ${percentage.toFixed(1)}%{/}`;
- content += percentageText;
- }
-
- // Add value display
- if (this.options.showValue) {
- const valueText = ` {white-fg}(${this.currentValue}/${this.options.max}){/}`;
- content += valueText;
- }
-
- return content;
- }
-
- /**
- * Calculate percentage from current value
- */
- calculatePercentage(value) {
- const range = this.options.max - this.options.min;
- const adjustedValue = value - this.options.min;
- return Math.max(0, Math.min(100, (adjustedValue / range) * 100));
- }
-
- /**
- * Get available width for the progress bar
- */
- getBarWidth() {
- // Account for percentage and value text
- let reservedWidth = 0;
-
- if (this.options.showPercentage) {
- reservedWidth += 7; // " 100.0%"
- }
-
- if (this.options.showValue) {
- const maxValueLength = this.options.max.toString().length;
- reservedWidth += maxValueLength * 2 + 4; // "(999/999)"
- }
-
- // Get actual container width
- const containerWidth = this.getActualWidth();
- return Math.max(10, containerWidth - reservedWidth);
- }
-
- /**
- * Get actual container width (handle percentage widths)
- */
- getActualWidth() {
- // For simplicity, assume a default width if percentage is used
- if (
- typeof this.options.width === "string" &&
- this.options.width.includes("%")
- ) {
- return 50; // Default assumption
- }
- return parseInt(this.options.width) || 50;
- }
-
- /**
- * Update progress bar display
- */
- updateDisplay() {
- if (this.progressBar) {
- this.progressBar.setContent(this.buildProgressContent());
-
- // Trigger screen render
- if (this.parent && this.parent.screen) {
- this.parent.screen.render();
- }
- }
- }
-
- /**
- * Set progress value with optional animation
- */
- setValue(value, animate = this.options.animated) {
- const clampedValue = Math.max(
- this.options.min,
- Math.min(this.options.max, value)
- );
- this.targetValue = clampedValue;
-
- if (animate && this.targetValue !== this.currentValue) {
- this.animateToValue();
- } else {
- this.currentValue = this.targetValue;
- this.updateDisplay();
- }
- }
-
- /**
- * Animate progress bar to target value
- */
- animateToValue() {
- if (this.isAnimating) {
- clearInterval(this.animationInterval);
- }
-
- this.isAnimating = true;
- const startValue = this.currentValue;
- const endValue = this.targetValue;
- const difference = endValue - startValue;
- const steps = Math.abs(difference);
- const stepSize = difference / Math.max(steps, 1);
-
- let currentStep = 0;
-
- this.animationInterval = setInterval(() => {
- currentStep++;
-
- if (currentStep >= steps) {
- // Animation complete
- this.currentValue = this.targetValue;
- this.isAnimating = false;
- clearInterval(this.animationInterval);
- } else {
- // Interpolate value
- this.currentValue = startValue + stepSize * currentStep;
- }
-
- this.updateDisplay();
- }, this.options.animationSpeed / steps);
- }
-
- /**
- * Get current progress value
- */
- getValue() {
- return this.currentValue;
- }
-
- /**
- * Get current percentage
- */
- getPercentage() {
- return this.calculatePercentage(this.currentValue);
- }
-
- /**
- * Set minimum value
- */
- setMin(min) {
- this.options.min = min;
- this.setValue(this.currentValue, false);
- }
-
- /**
- * Set maximum value
- */
- setMax(max) {
- this.options.max = max;
- this.setValue(this.currentValue, false);
- }
-
- /**
- * Set value range
- */
- setRange(min, max) {
- this.options.min = min;
- this.options.max = max;
- this.setValue(this.currentValue, false);
- }
-
- /**
- * Increment progress by specified amount
- */
- increment(amount = 1) {
- this.setValue(this.currentValue + amount);
- }
-
- /**
- * Decrement progress by specified amount
- */
- decrement(amount = 1) {
- this.setValue(this.currentValue - amount);
- }
-
- /**
- * Reset progress to minimum value
- */
- reset() {
- this.setValue(this.options.min, false);
- }
-
- /**
- * Complete progress (set to maximum)
- */
- complete() {
- this.setValue(this.options.max);
- }
-
- /**
- * Set progress bar label
- */
- setLabel(label) {
- this.options.label = label;
- if (this.labelText) {
- this.labelText.setContent(label);
- }
- this.updateDisplay();
- }
-
- /**
- * Set progress bar style
- */
- setStyle(styleOptions) {
- this.options.style = { ...this.options.style, ...styleOptions };
- this.updateDisplay();
- }
-
- /**
- * Set position
- */
- setPosition(top, left) {
- this.container.top = top;
- this.container.left = left;
- }
-
- /**
- * Set size
- */
- setSize(width, height) {
- this.container.width = width;
- this.container.height = height;
- this.options.width = width;
- this.options.height = height;
- this.updateDisplay();
- }
-
- /**
- * Show or hide the progress bar
- */
- setVisible(visible) {
- if (visible) {
- this.container.show();
- } else {
- this.container.hide();
- }
- }
-
- /**
- * Enable or disable animation
- */
- setAnimated(animated) {
- this.options.animated = animated;
-
- if (!animated && this.isAnimating) {
- clearInterval(this.animationInterval);
- this.isAnimating = false;
- this.currentValue = this.targetValue;
- this.updateDisplay();
- }
- }
-
- /**
- * Set animation speed
- */
- setAnimationSpeed(speed) {
- this.options.animationSpeed = speed;
- }
-
- /**
- * Check if progress bar is currently animating
- */
- isProgressAnimating() {
- return this.isAnimating;
- }
-
- /**
- * Stop current animation
- */
- stopAnimation() {
- if (this.isAnimating) {
- clearInterval(this.animationInterval);
- this.isAnimating = false;
- this.currentValue = this.targetValue;
- this.updateDisplay();
- }
- }
-
- /**
- * Cleanup resources
- */
- cleanup() {
- if (this.animationInterval) {
- clearInterval(this.animationInterval);
- this.animationInterval = null;
- }
- this.isAnimating = false;
- }
-
- /**
- * Destroy the progress bar component
- */
- destroy() {
- this.cleanup();
-
- if (this.container) {
- this.container.destroy();
- }
- }
-}
-
-module.exports = ProgressBar;
-
-
-
-src\tui\components\Router.jsx:
-
-const React = require("react");
-const { Box, Text } = require("ink");
-const { useAppState } = require("../providers/AppProvider");
-
-// Import screen components (will be created in later tasks)
-// const MainMenuScreen = require('./screens/MainMenuScreen');
-// const ConfigurationScreen = require('./screens/ConfigurationScreen');
-// const OperationScreen = require('./screens/OperationScreen');
-// const SchedulingScreen = require('./screens/SchedulingScreen');
-// const LogViewerScreen = require('./screens/LogViewerScreen');
-// const TagAnalysisScreen = require('./screens/TagAnalysisScreen');
-
-/**
- * Router Component
- * Manages screen navigation and renders the current screen
- * Requirements: 5.1, 5.3, 7.1
- */
-const Router = () => {
- const { appState } = useAppState();
-
- // Temporary placeholder screens until actual screens are implemented
- const screens = {
- "main-menu": () =>
- React.createElement(
- Box,
- { padding: 2 },
- React.createElement(Text, null, "Main Menu Screen - Coming Soon")
- ),
- configuration: () =>
- React.createElement(
- Box,
- { padding: 2 },
- React.createElement(Text, null, "Configuration Screen - Coming Soon")
- ),
- operation: () =>
- React.createElement(
- Box,
- { padding: 2 },
- React.createElement(Text, null, "Operation Screen - Coming Soon")
- ),
- scheduling: () =>
- React.createElement(
- Box,
- { padding: 2 },
- React.createElement(Text, null, "Scheduling Screen - Coming Soon")
- ),
- logs: () =>
- React.createElement(
- Box,
- { padding: 2 },
- React.createElement(Text, null, "Log Viewer Screen - Coming Soon")
- ),
- "tag-analysis": () =>
- React.createElement(
- Box,
- { padding: 2 },
- React.createElement(Text, null, "Tag Analysis Screen - Coming Soon")
- ),
- };
-
- // Get the current screen component
- const CurrentScreen = screens[appState.currentScreen] || screens["main-menu"];
-
- return React.createElement(
- Box,
- { flexGrow: 1 },
- React.createElement(CurrentScreen)
- );
-};
-
-module.exports = Router;
-
-
-
-src\tui\components\StatusBar.jsx:
-
-const React = require("react");
-const { Box, Text } = require("ink");
-const { useAppState } = require("../providers/AppProvider");
-
-/**
- * StatusBar Component
- * Displays global status information at the top of the application
- * Requirements: 8.1, 8.2, 8.3
- */
-const StatusBar = () => {
- const { appState } = useAppState();
-
- // Get connection status (placeholder for now)
- const connectionStatus = "Connected"; // Will be dynamic later
- const connectionColor = "green";
-
- // Get operation progress
- const operationProgress = appState.operationState?.progress || 0;
-
- // Get current screen name for display
- const screenNames = {
- "main-menu": "Main Menu",
- configuration: "Configuration",
- operation: "Operation",
- scheduling: "Scheduling",
- logs: "Logs",
- "tag-analysis": "Tag Analysis",
- };
-
- const currentScreenName = screenNames[appState.currentScreen] || "Unknown";
-
- return React.createElement(
- Box,
- {
- borderStyle: "single",
- paddingX: 1,
- justifyContent: "space-between",
- },
- React.createElement(
- Box,
- null,
- React.createElement(Text, { color: connectionColor }, "● "),
- React.createElement(Text, null, connectionStatus),
- React.createElement(Text, null, " | "),
- React.createElement(Text, null, `Screen: ${currentScreenName}`)
- ),
- React.createElement(
- Box,
- null,
- appState.operationState &&
- React.createElement(Text, null, `Progress: ${operationProgress}%`)
- )
- );
-};
-
-module.exports = StatusBar;
-
-
-
-src\tui\providers\AppProvider.jsx:
-
-const React = require("react");
-const { useState, createContext, useContext } = React;
-
-/**
- * Application Context for global state management
- * Requirements: 5.1, 5.3, 7.1
- */
-const AppContext = createContext();
-
-/**
- * Initial application state
- */
-const initialState = {
- currentScreen: "main-menu",
- navigationHistory: [],
- configuration: {
- shopifyDomain: "",
- accessToken: "",
- targetTag: "",
- priceAdjustment: 0,
- operationMode: "update",
- isValid: false,
- lastTested: null,
- },
- operationState: null,
- uiState: {
- focusedComponent: "menu",
- modalOpen: false,
- selectedMenuIndex: 0,
- scrollPosition: 0,
- },
-};
-
-/**
- * AppProvider Component
- * Provides global state management using React Context
- */
-const AppProvider = ({ children }) => {
- const [appState, setAppState] = useState(initialState);
-
- /**
- * Navigate to a new screen
- */
- const navigateTo = (screen) => {
- setAppState((prevState) => ({
- ...prevState,
- navigationHistory: [
- ...prevState.navigationHistory,
- prevState.currentScreen,
- ],
- currentScreen: screen,
- }));
- };
-
- /**
- * Navigate back to previous screen
- */
- const navigateBack = () => {
- setAppState((prevState) => {
- const history = [...prevState.navigationHistory];
- const previousScreen = history.pop() || "main-menu";
-
- return {
- ...prevState,
- currentScreen: previousScreen,
- navigationHistory: history,
- };
- });
- };
-
- /**
- * Update configuration
- */
- const updateConfiguration = (updates) => {
- setAppState((prevState) => ({
- ...prevState,
- configuration: {
- ...prevState.configuration,
- ...updates,
- },
- }));
- };
-
- /**
- * Update operation state
- */
- const updateOperationState = (operationState) => {
- setAppState((prevState) => ({
- ...prevState,
- operationState,
- }));
- };
-
- /**
- * Update UI state
- */
- const updateUIState = (updates) => {
- setAppState((prevState) => ({
- ...prevState,
- uiState: {
- ...prevState.uiState,
- ...updates,
- },
- }));
- };
-
- const contextValue = {
- appState,
- setAppState,
- navigateTo,
- navigateBack,
- updateConfiguration,
- updateOperationState,
- updateUIState,
- };
-
- return React.createElement(
- AppContext.Provider,
- { value: contextValue },
- children
- );
-};
-
-/**
- * Custom hook to use app context
- */
-const useAppState = () => {
- const context = useContext(AppContext);
- if (!context) {
- throw new Error("useAppState must be used within an AppProvider");
- }
- return context;
-};
-
-module.exports = AppProvider;
-module.exports.useAppState = useAppState;
-module.exports.AppContext = AppContext;
-
-
-
-src\tui\TuiApplication.jsx:
-
-const React = require("react");
-const { Box, Text } = require("ink");
-const AppProvider = require("./providers/AppProvider");
-const Router = require("./components/Router");
-const StatusBar = require("./components/StatusBar");
-
-/**
- * Main TUI Application Component
- * Root component that sets up the application structure
- * Requirements: 2.2, 2.5
- */
-const TuiApplication = () => {
- return React.createElement(
- AppProvider,
- null,
- React.createElement(
- Box,
- { flexDirection: "column", height: "100%" },
- React.createElement(StatusBar),
- React.createElement(Router)
- )
- );
-};
-
-module.exports = TuiApplication;
-
-
-
-src\utils\logger.js:
-
-const ProgressService = require("../services/progress");
-
-class Logger {
- constructor(progressService = null) {
- this.progressService = progressService || new ProgressService();
- this.colors = {
- reset: "\x1b[0m",
- bright: "\x1b[1m",
- red: "\x1b[31m",
- green: "\x1b[32m",
- yellow: "\x1b[33m",
- blue: "\x1b[34m",
- cyan: "\x1b[36m",
- };
- }
-
- /**
- * Formats a timestamp for console display
- * @param {Date} date - The date to format
- * @returns {string} Formatted timestamp string
- */
- formatTimestamp(date = new Date()) {
- return date
- .toISOString()
- .replace("T", " ")
- .replace(/\.\d{3}Z$/, "");
- }
-
- /**
- * Formats a console message with color and timestamp
- * @param {string} level - Log level (INFO, WARN, ERROR)
- * @param {string} message - Message to log
- * @param {string} color - ANSI color code
- * @returns {string} Formatted message
- */
- formatConsoleMessage(level, message, color) {
- const timestamp = this.formatTimestamp();
- return `${color}[${timestamp}] ${level}:${this.colors.reset} ${message}`;
- }
-
- /**
- * Logs an info message to console
- * @param {string} message - Message to log
- * @returns {Promise}
- */
- async info(message) {
- const formattedMessage = this.formatConsoleMessage(
- "INFO",
- message,
- this.colors.cyan
- );
- console.log(formattedMessage);
- }
-
- /**
- * Logs a warning message to console
- * @param {string} message - Message to log
- * @returns {Promise}
- */
- async warning(message) {
- const formattedMessage = this.formatConsoleMessage(
- "WARN",
- message,
- this.colors.yellow
- );
- console.warn(formattedMessage);
- }
-
- /**
- * Logs an error message to console
- * @param {string} message - Message to log
- * @returns {Promise}
- */
- async error(message) {
- const formattedMessage = this.formatConsoleMessage(
- "ERROR",
- message,
- this.colors.red
- );
- console.error(formattedMessage);
- }
-
- /**
- * Logs operation start with configuration details (Requirement 3.1)
- * @param {Object} config - Configuration object
- * @param {Object} schedulingContext - Optional scheduling context
- * @returns {Promise}
- */
- async logOperationStart(config, schedulingContext = null) {
- if (schedulingContext && schedulingContext.isScheduled) {
- await this.info(
- `Starting scheduled price update operation with configuration:`
- );
- await this.info(
- ` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
- );
- await this.info(
- ` Original Schedule Input: ${schedulingContext.originalInput}`
- );
- } else {
- await this.info(`Starting price update operation with configuration:`);
- }
-
- await this.info(` Target Tag: ${config.targetTag}`);
- await this.info(` Price Adjustment: ${config.priceAdjustmentPercentage}%`);
- await this.info(` Shop Domain: ${config.shopDomain}`);
-
- // Also log to progress file with scheduling context
- await this.progressService.logOperationStart(config, schedulingContext);
- }
-
- /**
- * Logs rollback operation start with configuration details (Requirements 3.1, 7.1, 8.3)
- * @param {Object} config - Configuration object
- * @param {Object} schedulingContext - Optional scheduling context
- * @returns {Promise}
- */
- async logRollbackStart(config, schedulingContext = null) {
- if (schedulingContext && schedulingContext.isScheduled) {
- await this.info(
- `Starting scheduled price rollback operation with configuration:`
- );
- await this.info(
- ` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
- );
- await this.info(
- ` Original Schedule Input: ${schedulingContext.originalInput}`
- );
- } else {
- 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 and scheduling context
- try {
- await this.progressService.logRollbackStart(config, schedulingContext);
- } 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
- * @returns {Promise}
- */
- async logProductCount(count) {
- const message = `Found ${count} product${
- count !== 1 ? "s" : ""
- } matching the specified tag`;
- await this.info(message);
- }
-
- /**
- * Logs individual product update details (Requirement 3.3)
- * @param {Object} entry - Product 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
- * @param {number} entry.newPrice - Updated price
- * @returns {Promise}
- */
- async logProductUpdate(entry) {
- const compareAtInfo = entry.compareAtPrice
- ? ` (Compare At: $${entry.compareAtPrice})`
- : "";
- const message = `${this.colors.green}✅${this.colors.reset} Updated "${entry.productTitle}" - Price: $${entry.oldPrice} → $${entry.newPrice}${compareAtInfo}`;
- console.log(message);
-
- // Also log to progress file
- 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
- * @param {number} summary.totalProducts - Total products processed
- * @param {number} summary.successfulUpdates - Successful updates
- * @param {number} summary.failedUpdates - Failed updates
- * @param {Date} summary.startTime - Operation start time
- * @returns {Promise}
- */
- async logCompletionSummary(summary) {
- await this.info("=".repeat(50));
- await this.info("OPERATION COMPLETE");
- await this.info("=".repeat(50));
- await this.info(`Total Products Processed: ${summary.totalProducts}`);
- await this.info(
- `Successful Updates: ${this.colors.green}${summary.successfulUpdates}${this.colors.reset}`
- );
-
- if (summary.failedUpdates > 0) {
- await this.info(
- `Failed Updates: ${this.colors.red}${summary.failedUpdates}${this.colors.reset}`
- );
- } else {
- await this.info(`Failed Updates: ${summary.failedUpdates}`);
- }
-
- if (summary.startTime) {
- const duration = Math.round((new Date() - summary.startTime) / 1000);
- await this.info(`Duration: ${duration} seconds`);
- }
-
- // Also log to progress file
- 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
- * @param {string} entry.productTitle - Product title
- * @param {string} entry.productId - Product ID
- * @param {string} entry.variantId - Variant ID (optional)
- * @param {string} entry.errorMessage - Error message
- * @param {Object} schedulingContext - Optional scheduling context for error logging (Requirements 5.3, 5.4)
- * @returns {Promise}
- */
- async logProductError(entry, schedulingContext = null) {
- const variantInfo = entry.variantId ? ` (Variant: ${entry.variantId})` : "";
- const schedulingInfo =
- schedulingContext && schedulingContext.isScheduled
- ? ` [Scheduled Operation]`
- : "";
- const message = `${this.colors.red}❌${this.colors.reset} Failed to update "${entry.productTitle}"${variantInfo}: ${entry.errorMessage}${schedulingInfo}`;
- console.error(message);
-
- // Also log to progress file with scheduling context
- await this.progressService.logError(entry, schedulingContext);
- }
-
- /**
- * Logs API rate limiting information
- * @param {number} retryAfter - Seconds to wait before retry
- * @returns {Promise}
- */
- async logRateLimit(retryAfter) {
- await this.warning(
- `Rate limit encountered. Waiting ${retryAfter} seconds before retry...`
- );
- }
-
- /**
- * Logs network retry attempts
- * @param {number} attempt - Current attempt number
- * @param {number} maxAttempts - Maximum attempts
- * @param {string} error - Error message
- * @returns {Promise}
- */
- async logRetryAttempt(attempt, maxAttempts, error) {
- await this.warning(
- `Network error (attempt ${attempt}/${maxAttempts}): ${error}. Retrying...`
- );
- }
-
- /**
- * Logs when a product is skipped due to invalid data
- * @param {string} productTitle - Product title
- * @param {string} reason - Reason for skipping
- * @returns {Promise}
- */
- async logSkippedProduct(productTitle, reason) {
- await this.warning(`Skipped "${productTitle}": ${reason}`);
- }
-
- /**
- * Logs scheduling confirmation with operation details (Requirements 2.1, 2.3)
- * @param {Object} schedulingInfo - Scheduling information
- * @param {Date} schedulingInfo.scheduledTime - Target execution time
- * @param {string} schedulingInfo.originalInput - Original datetime input
- * @param {string} schedulingInfo.operationType - Type of operation (update/rollback)
- * @param {Object} schedulingInfo.config - Operation configuration
- * @returns {Promise}
- */
- async logSchedulingConfirmation(schedulingInfo) {
- const { scheduledTime, originalInput, operationType, config } =
- schedulingInfo;
-
- await this.info("=".repeat(50));
- await this.info("SCHEDULED OPERATION CONFIRMED");
- await this.info("=".repeat(50));
- await this.info(`Operation Type: ${operationType}`);
- await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
- await this.info(`Original Input: ${originalInput}`);
- await this.info(`Target Tag: ${config.targetTag}`);
-
- if (operationType === "update") {
- await this.info(`Price Adjustment: ${config.priceAdjustmentPercentage}%`);
- }
-
- await this.info(`Shop Domain: ${config.shopDomain}`);
-
- const delay = scheduledTime.getTime() - new Date().getTime();
- const timeRemaining = this.formatTimeRemaining(delay);
- await this.info(`Time Remaining: ${timeRemaining}`);
- await this.info("Press Ctrl+C to cancel the scheduled operation");
- await this.info("=".repeat(50));
-
- // Also log to progress file
- await this.progressService.logSchedulingConfirmation(schedulingInfo);
- }
-
- /**
- * Logs countdown updates during scheduled wait period (Requirements 2.2, 2.3)
- * @param {Object} countdownInfo - Countdown information
- * @param {Date} countdownInfo.scheduledTime - Target execution time
- * @param {number} countdownInfo.remainingMs - Milliseconds remaining
- * @returns {Promise}
- */
- async logCountdownUpdate(countdownInfo) {
- const { scheduledTime, remainingMs } = countdownInfo;
- const timeRemaining = this.formatTimeRemaining(remainingMs);
-
- await this.info(
- `Scheduled execution in: ${timeRemaining} (at ${scheduledTime.toLocaleString()})`
- );
- }
-
- /**
- * Logs the start of scheduled operation execution (Requirements 2.3, 5.4)
- * @param {Object} executionInfo - Execution information
- * @param {Date} executionInfo.scheduledTime - Original scheduled time
- * @param {Date} executionInfo.actualTime - Actual execution time
- * @param {string} executionInfo.operationType - Type of operation
- * @returns {Promise}
- */
- async logScheduledExecutionStart(executionInfo) {
- const { scheduledTime, actualTime, operationType } = executionInfo;
- const delay = actualTime.getTime() - scheduledTime.getTime();
- const delayText =
- Math.abs(delay) < 1000
- ? "on time"
- : delay > 0
- ? `${Math.round(delay / 1000)}s late`
- : `${Math.round(Math.abs(delay) / 1000)}s early`;
-
- await this.info("=".repeat(50));
- await this.info("SCHEDULED OPERATION STARTING");
- await this.info("=".repeat(50));
- await this.info(`Operation Type: ${operationType}`);
- await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
- await this.info(`Actual Start Time: ${actualTime.toLocaleString()}`);
- await this.info(`Timing: ${delayText}`);
- await this.info("=".repeat(50));
-
- // Also log to progress file
- await this.progressService.logScheduledExecutionStart(executionInfo);
- }
-
- /**
- * Logs scheduled operation cancellation (Requirements 3.1, 3.2)
- * @param {Object} cancellationInfo - Cancellation information
- * @param {Date} cancellationInfo.scheduledTime - Original scheduled time
- * @param {Date} cancellationInfo.cancelledTime - Time when cancelled
- * @param {string} cancellationInfo.operationType - Type of operation
- * @param {string} cancellationInfo.reason - Cancellation reason
- * @returns {Promise}
- */
- async logScheduledOperationCancellation(cancellationInfo) {
- const { scheduledTime, cancelledTime, operationType, reason } =
- cancellationInfo;
- const timeRemaining = scheduledTime.getTime() - cancelledTime.getTime();
- const remainingText = this.formatTimeRemaining(timeRemaining);
-
- await this.info("=".repeat(50));
- await this.info("SCHEDULED OPERATION CANCELLED");
- await this.info("=".repeat(50));
- await this.info(`Operation Type: ${operationType}`);
- await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
- await this.info(`Cancelled Time: ${cancelledTime.toLocaleString()}`);
- await this.info(`Time Remaining: ${remainingText}`);
- await this.info(`Reason: ${reason}`);
- await this.info("=".repeat(50));
-
- // Also log to progress file
- await this.progressService.logScheduledOperationCancellation(
- cancellationInfo
- );
- }
-
- /**
- * Format time remaining into human-readable string
- * @param {number} milliseconds - Time remaining in milliseconds
- * @returns {string} Formatted time string (e.g., "2h 30m 15s")
- */
- formatTimeRemaining(milliseconds) {
- if (milliseconds <= 0) {
- return "0s";
- }
-
- const seconds = Math.floor(milliseconds / 1000);
- const minutes = Math.floor(seconds / 60);
- const hours = Math.floor(minutes / 60);
- const days = Math.floor(hours / 24);
-
- const remainingHours = hours % 24;
- const remainingMinutes = minutes % 60;
- const remainingSeconds = seconds % 60;
-
- const parts = [];
-
- if (days > 0) parts.push(`${days}d`);
- if (remainingHours > 0) parts.push(`${remainingHours}h`);
- if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
- if (remainingSeconds > 0 || parts.length === 0)
- parts.push(`${remainingSeconds}s`);
-
- return parts.join(" ");
- }
-
- /**
- * Logs comprehensive error analysis and recommendations
- * @param {Array} errors - Array of error objects
- * @param {Object} summary - Operation summary statistics
- * @param {Object} schedulingContext - Optional scheduling context for error analysis (Requirements 5.3, 5.4)
- * @returns {Promise}
- */
- async logErrorAnalysis(errors, summary, schedulingContext = null) {
- if (!errors || errors.length === 0) {
- return;
- }
-
- const operationType =
- summary.successfulRollbacks !== undefined ? "ROLLBACK" : "UPDATE";
- const schedulingPrefix =
- schedulingContext && schedulingContext.isScheduled ? "SCHEDULED " : "";
-
- await this.info("=".repeat(50));
- await this.info(`${schedulingPrefix}${operationType} ERROR ANALYSIS`);
- await this.info("=".repeat(50));
-
- if (schedulingContext && schedulingContext.isScheduled) {
- await this.info(
- `Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
- );
- await this.info(
- `Original Schedule Input: ${schedulingContext.originalInput}`
- );
- await this.info("=".repeat(50));
- }
-
- // Enhanced categorization for rollback operations
- const categories = {};
- const retryableErrors = [];
- const nonRetryableErrors = [];
-
- errors.forEach((error) => {
- const category = this.categorizeError(
- error.errorMessage || error.error || "Unknown"
- );
- if (!categories[category]) {
- 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
- await this.info("Error Categories:");
- Object.entries(categories).forEach(([category, categoryErrors]) => {
- const percentage = (
- (categoryErrors.length / errors.length) *
- 100
- ).toFixed(1);
- this.info(
- ` ${category}: ${categoryErrors.length} errors (${percentage}%)`
- );
- });
-
- // 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, operationType);
-
- // Log to progress file as well with scheduling context
- await this.progressService.logErrorAnalysis(errors, schedulingContext);
- }
-
- /**
- * Categorize error for analysis (same logic as progress service)
- * @param {string} errorMessage - Error message to categorize
- * @returns {string} Error category
- */
- categorizeError(errorMessage) {
- const message = errorMessage.toLowerCase();
-
- if (
- message.includes("rate limit") ||
- message.includes("429") ||
- message.includes("throttled")
- ) {
- return "Rate Limiting";
- }
- if (
- message.includes("network") ||
- message.includes("connection") ||
- message.includes("timeout")
- ) {
- return "Network Issues";
- }
- if (
- message.includes("authentication") ||
- message.includes("unauthorized") ||
- message.includes("401")
- ) {
- return "Authentication";
- }
- if (
- message.includes("permission") ||
- message.includes("forbidden") ||
- message.includes("403")
- ) {
- return "Permissions";
- }
- if (message.includes("not found") || message.includes("404")) {
- return "Resource Not Found";
- }
- if (
- message.includes("validation") ||
- message.includes("invalid") ||
- message.includes("price")
- ) {
- return "Data Validation";
- }
- if (
- message.includes("server error") ||
- message.includes("500") ||
- message.includes("502") ||
- message.includes("503")
- ) {
- return "Server Errors";
- }
- if (message.includes("shopify") && message.includes("api")) {
- return "Shopify API";
- }
-
- return "Other";
- }
-
- /**
- * 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,
- operationType = "UPDATE"
- ) {
- if (categories["Rate Limiting"]) {
- await this.info(
- " • Consider reducing batch size or adding delays between requests"
- );
- await this.info(
- " • Check if your API plan supports the current request volume"
- );
- }
-
- 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"]) {
- await this.info(
- " • Verify your Shopify access token is valid and not expired"
- );
- await this.info(" • Check that your app has the required permissions");
- }
-
- if (categories["Data Validation"]) {
- if (operationType === "ROLLBACK") {
- await this.info(
- " • Products without compare-at prices cannot be rolled back"
- );
- await this.info(
- " • Consider filtering products to only include those with compare-at prices"
- );
- await this.info(
- " • Review which products were updated in the original price adjustment"
- );
- } else {
- await this.info(
- " • Review product data for invalid prices or missing information"
- );
- 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");
- }
- }
-
- // 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 (${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 (${successRate}%) - some optimization may be beneficial`
- );
- } else {
- await this.info(
- ` • Good success rate (${successRate}%) - most operations completed successfully`
- );
- }
- }
-}
-
-module.exports = Logger;
-
-
-
-src\utils\price.js:
-
-/**
- * Price calculation utilities for Shopify price updates
- * Handles percentage-based price adjustments with proper validation and rounding
- */
-
-/**
- * Calculates a new price based on percentage adjustment
- * @param {number} originalPrice - The original price as a number
- * @param {number} percentage - The percentage to adjust (positive for increase, negative for decrease)
- * @returns {number} The new price rounded to 2 decimal places
- * @throws {Error} If inputs are invalid
- */
-function calculateNewPrice(originalPrice, percentage) {
- // Validate inputs
- if (typeof originalPrice !== "number" || isNaN(originalPrice)) {
- throw new Error("Original price must be a valid number");
- }
-
- if (typeof percentage !== "number" || isNaN(percentage)) {
- throw new Error("Percentage must be a valid number");
- }
-
- if (originalPrice < 0) {
- throw new Error("Original price cannot be negative");
- }
-
- // Handle zero price edge case
- if (originalPrice === 0) {
- return 0;
- }
-
- // Calculate the adjustment amount
- const adjustmentAmount = originalPrice * (percentage / 100);
- const newPrice = originalPrice + adjustmentAmount;
-
- // Ensure the new price is not negative
- if (newPrice < 0) {
- throw new Error(
- `Price adjustment would result in negative price: ${newPrice.toFixed(2)}`
- );
- }
-
- // Round to 2 decimal places for currency
- return Math.round(newPrice * 100) / 100;
-}
-
-/**
- * Validates if a price is within acceptable ranges
- * @param {number} price - The price to validate
- * @returns {boolean} True if price is valid, false otherwise
- */
-function isValidPrice(price) {
- if (typeof price !== "number" || isNaN(price)) {
- return false;
- }
-
- // Price must be non-negative and finite
- return price >= 0 && isFinite(price);
-}
-
-/**
- * Formats a price for display with proper currency formatting
- * @param {number} price - The price to format
- * @returns {string} Formatted price string
- */
-function formatPrice(price) {
- if (!isValidPrice(price)) {
- return "Invalid Price";
- }
-
- return price.toFixed(2);
-}
-
-/**
- * Calculates the percentage change between two prices
- * @param {number} oldPrice - The original price
- * @param {number} newPrice - The new price
- * @returns {number} The percentage change (positive for increase, negative for decrease)
- */
-function calculatePercentageChange(oldPrice, newPrice) {
- if (!isValidPrice(oldPrice) || !isValidPrice(newPrice)) {
- throw new Error("Both prices must be valid numbers");
- }
-
- if (oldPrice === 0) {
- return newPrice === 0 ? 0 : Infinity;
- }
-
- const change = ((newPrice - oldPrice) / oldPrice) * 100;
- return Math.round(change * 100) / 100; // Round to 2 decimal places
-}
-
-/**
- * Validates a percentage value for price adjustment
- * @param {number} percentage - The percentage to validate
- * @returns {boolean} True if percentage is valid, false otherwise
- */
-function isValidPercentage(percentage) {
- if (typeof percentage !== "number" || isNaN(percentage)) {
- return false;
- }
-
- // Allow any finite percentage (including negative for decreases)
- return isFinite(percentage);
-}
-
-/**
- * Prepares a price update object with both new price and Compare At price
- * @param {number} originalPrice - The original price before adjustment
- * @param {number} percentage - The percentage to adjust (positive for increase, negative for decrease)
- * @returns {Object} Object containing newPrice and compareAtPrice
- * @throws {Error} If inputs are invalid
- */
-function preparePriceUpdate(originalPrice, percentage) {
- // Validate inputs using existing validation
- if (!isValidPrice(originalPrice)) {
- throw new Error("Original price must be a valid number");
- }
-
- if (!isValidPercentage(percentage)) {
- throw new Error("Percentage must be a valid number");
- }
-
- // Calculate the new price
- const newPrice = calculateNewPrice(originalPrice, percentage);
-
- // The Compare At price should be the original price (before adjustment)
- const compareAtPrice = originalPrice;
-
- return {
- newPrice,
- compareAtPrice,
- };
-}
-
-/**
- * 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,
- formatPrice,
- calculatePercentageChange,
- isValidPercentage,
- preparePriceUpdate,
- validateRollbackEligibility,
- prepareRollbackUpdate,
-};
-
-
-
-src\index.js:
-
-#!/usr/bin/env node
-
-/**
- * Shopify Price Updater - Main Application Entry Point
- *
- * This script connects to Shopify's GraphQL API to update product prices
- * based on specific tag criteria and configurable percentage adjustments.
- */
-
-const { getConfig } = require("./config/environment");
-const ProductService = require("./services/product");
-const ScheduleService = require("./services/schedule");
-const Logger = require("./utils/logger");
-
-/**
- * Main application class that orchestrates the price update workflow
- */
-class ShopifyPriceUpdater {
- constructor() {
- this.logger = new Logger();
- this.productService = new ProductService();
- this.scheduleService = new ScheduleService(this.logger);
- this.config = null;
- this.startTime = null;
- }
-
- /**
- * Initialize the application and load configuration
- * @returns {Promise} True if initialization successful
- */
- async initialize() {
- try {
- // Load and validate configuration
- this.config = getConfig();
-
- // 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) {
- await this.logger.error(`Initialization failed: ${error.message}`);
- return false;
- }
- }
-
- /**
- * Test connection to Shopify API
- * @returns {Promise} True if connection successful
- */
- async testConnection() {
- try {
- await this.logger.info("Testing connection to Shopify API...");
- const isConnected =
- await this.productService.shopifyService.testConnection();
-
- if (!isConnected) {
- await this.logger.error(
- "Failed to connect to Shopify API. Please check your credentials."
- );
- return false;
- }
-
- await this.logger.info("Successfully connected to Shopify API");
- return true;
- } catch (error) {
- await this.logger.error(`Connection test failed: ${error.message}`);
- return false;
- }
- }
-
- /**
- * Fetch products by tag and validate them
- * @returns {Promise} Array of valid products or null if failed
- */
- async fetchAndValidateProducts() {
- 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 price updates
- const validProducts = await this.productService.validateProducts(
- products
- );
-
- // Display summary statistics
- const summary = this.productService.getProductSummary(validProducts);
- await this.logger.info(`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 validProducts;
- } catch (error) {
- await this.logger.error(`Failed to fetch products: ${error.message}`);
- return null;
- }
- }
-
- /**
- * Update prices for all products
- * @param {Array} products - Array of products to update
- * @returns {Promise} Update results or null if failed
- */
- async updatePrices(products) {
- try {
- if (products.length === 0) {
- return {
- totalProducts: 0,
- totalVariants: 0,
- successfulUpdates: 0,
- failedUpdates: 0,
- errors: [],
- };
- }
-
- await this.logger.info(
- `Starting price updates with ${this.config.priceAdjustmentPercentage}% adjustment`
- );
-
- // Mark operation as in progress to prevent cancellation during updates
- if (this.setOperationInProgress) {
- this.setOperationInProgress(true);
- }
-
- try {
- // Update product prices
- const results = await this.productService.updateProductPrices(
- products,
- this.config.priceAdjustmentPercentage
- );
-
- return results;
- } finally {
- // Mark operation as complete
- if (this.setOperationInProgress) {
- this.setOperationInProgress(false);
- }
- }
- } catch (error) {
- // Ensure operation state is cleared on error
- if (this.setOperationInProgress) {
- this.setOperationInProgress(false);
- }
- await this.logger.error(`Price update failed: ${error.message}`);
- return null;
- }
- }
-
- /**
- * Display final summary and determine exit status
- * @param {Object} results - Update results
- * @returns {number} Exit status code
- */
- async displaySummaryAndGetExitCode(results) {
- // Prepare comprehensive summary for logging (Requirement 3.4)
- const summary = {
- totalProducts: results.totalProducts,
- totalVariants: results.totalVariants,
- successfulUpdates: results.successfulUpdates,
- failedUpdates: results.failedUpdates,
- startTime: this.startTime,
- errors: results.errors || [],
- };
-
- // Log completion summary
- await this.logger.logCompletionSummary(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 (Requirement 4.5)
- const successRate =
- summary.totalVariants > 0
- ? (summary.successfulUpdates / summary.totalVariants) * 100
- : 0;
-
- if (results.failedUpdates === 0) {
- await this.logger.info("🎉 All operations completed successfully!");
- return 0; // Success
- } else if (results.successfulUpdates > 0) {
- if (successRate >= 90) {
- await this.logger.info(
- `✅ Operation 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(
- `⚠️ Operation completed with moderate success rate (${successRate.toFixed(
- 1
- )}%). Review errors above.`
- );
- return 1; // Partial failure
- } else {
- await this.logger.error(
- `❌ Operation completed with low success rate (${successRate.toFixed(
- 1
- )}%). Significant issues detected.`
- );
- return 2; // Poor success rate
- }
- } else {
- await this.logger.error(
- "❌ All update operations failed. Please check your configuration and try again."
- );
- return 2; // Complete failure
- }
- }
-
- /**
- * 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`);
-
- // Mark operation as in progress to prevent cancellation during rollback
- if (this.setOperationInProgress) {
- this.setOperationInProgress(true);
- }
-
- try {
- // Execute rollback operations
- const results = await this.productService.rollbackProductPrices(
- products
- );
-
- return results;
- } finally {
- // Mark operation as complete
- if (this.setOperationInProgress) {
- this.setOperationInProgress(false);
- }
- }
- } catch (error) {
- // Ensure operation state is cleared on error
- if (this.setOperationInProgress) {
- this.setOperationInProgress(false);
- }
- 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() {
- this.startTime = new Date();
- let operationResults = null;
-
- try {
- // Initialize application with enhanced error handling
- const initialized = await this.safeInitialize();
- if (!initialized) {
- return await this.handleCriticalFailure("Initialization failed", 1);
- }
-
- // Test API connection with enhanced error handling
- const connected = await this.safeTestConnection();
- if (!connected) {
- return await this.handleCriticalFailure("API connection failed", 1);
- }
-
- // Check for scheduled execution and handle scheduling if configured
- if (this.config.isScheduled) {
- const shouldProceed = await this.handleScheduledExecution();
- if (!shouldProceed) {
- return 0; // Operation was cancelled during scheduling
- }
- }
-
- // Display operation mode indication in console output (Requirements 9.3, 8.4)
- await this.displayOperationModeHeader();
-
- // Operation mode detection logic - route to appropriate workflow (Requirements 8.1, 9.1, 9.2)
- if (this.config.operationMode === "rollback") {
- // Rollback workflow
- const products = await this.safeFetchAndValidateProductsForRollback();
- if (products === null) {
- return await this.handleCriticalFailure(
- "Product fetching for rollback failed",
- 1
- );
- }
-
- operationResults = await this.safeRollbackPrices(products);
- if (operationResults === null) {
- return await this.handleCriticalFailure(
- "Price rollback process failed",
- 1
- );
- }
-
- // Display rollback-specific summary and determine exit code
- return await this.displayRollbackSummary(operationResults);
- } else {
- // Default update workflow (Requirements 9.4, 9.5 - backward compatibility)
- const products = await this.safeFetchAndValidateProducts();
- if (products === null) {
- return await this.handleCriticalFailure("Product fetching failed", 1);
- }
-
- 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);
- return 2; // Unexpected error
- }
- }
-
- /**
- * Handle scheduled execution workflow
- * @returns {Promise} True if execution should proceed, false if cancelled
- */
- async handleScheduledExecution() {
- try {
- // Use the already validated scheduled time from config
- const scheduledTime = this.config.scheduledExecutionTime;
-
- // Display scheduling confirmation and countdown
- await this.logger.info("🕐 Scheduled execution mode activated");
- await this.scheduleService.displayScheduleInfo(scheduledTime);
-
- // Wait until scheduled time with cancellation support
- const shouldProceed = await this.scheduleService.waitUntilScheduledTime(
- scheduledTime,
- () => {
- // Cancellation callback - log the cancellation
- this.logger.info("Scheduled operation cancelled by user");
- }
- );
-
- if (!shouldProceed) {
- // Update scheduling state - no longer waiting
- if (this.setSchedulingActive) {
- this.setSchedulingActive(false);
- }
- await this.logger.info("Operation cancelled. Exiting gracefully.");
- return false;
- }
-
- // Scheduling wait period is complete, operations will begin
- if (this.setSchedulingActive) {
- this.setSchedulingActive(false);
- }
-
- // Log that scheduled execution is starting
- await this.logger.info(
- "⏰ Scheduled time reached. Beginning operation..."
- );
- return true;
- } catch (error) {
- // Update scheduling state on error
- if (this.setSchedulingActive) {
- this.setSchedulingActive(false);
- }
- await this.logger.error(`Scheduling error: ${error.message}`);
- return false;
- }
- }
-
- /**
- * Safe wrapper for initialization with enhanced error handling
- * @returns {Promise} True if successful
- */
- async safeInitialize() {
- try {
- return await this.initialize();
- } catch (error) {
- await this.logger.error(`Initialization error: ${error.message}`);
- if (error.stack) {
- console.error("Stack trace:", error.stack);
- }
- return false;
- }
- }
-
- /**
- * Safe wrapper for connection testing with enhanced error handling
- * @returns {Promise} True if successful
- */
- async safeTestConnection() {
- try {
- return await this.testConnection();
- } catch (error) {
- await this.logger.error(`Connection test error: ${error.message}`);
- if (error.errorHistory) {
- await this.logger.error(
- `Connection attempts made: ${error.totalAttempts || "Unknown"}`
- );
- }
- return false;
- }
- }
-
- /**
- * Safe wrapper for product fetching with enhanced error handling
- * @returns {Promise} Products array or null if failed
- */
- async safeFetchAndValidateProducts() {
- try {
- return await this.fetchAndValidateProducts();
- } catch (error) {
- await this.logger.error(`Product fetching error: ${error.message}`);
- if (error.errorHistory) {
- await this.logger.error(
- `Fetch attempts made: ${error.totalAttempts || "Unknown"}`
- );
- }
- return null;
- }
- }
-
- /**
- * Safe wrapper for price updates with enhanced error handling
- * @param {Array} products - Products to update
- * @returns {Promise} Update results or null if failed
- */
- async safeUpdatePrices(products) {
- try {
- return await this.updatePrices(products);
- } catch (error) {
- await this.logger.error(`Price update error: ${error.message}`);
- if (error.errorHistory) {
- await this.logger.error(
- `Update 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
- ),
- successfulUpdates: 0,
- failedUpdates: products.reduce(
- (sum, p) => sum + (p.variants?.length || 0),
- 0
- ),
- errors: [
- {
- productTitle: "System Error",
- productId: "N/A",
- errorMessage: error.message,
- },
- ],
- };
- }
- }
-
- /**
- * 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 in ${
- this.config?.operationMode || "unknown"
- } mode: ${message}`
- );
-
- // Ensure progress logging continues even for critical failures
- // Use appropriate summary format based on operation mode
- try {
- if (this.config?.operationMode === "rollback") {
- const summary = {
- totalProducts: 0,
- totalVariants: 0,
- eligibleVariants: 0,
- successfulRollbacks: 0,
- failedRollbacks: 0,
- skippedVariants: 0,
- startTime: this.startTime,
- errors: [
- {
- productTitle: "Critical System Error",
- productId: "N/A",
- errorMessage: message,
- },
- ],
- };
- await this.logger.logRollbackSummary(summary);
- } else {
- const summary = {
- totalProducts: 0,
- totalVariants: 0,
- 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:",
- loggingError.message
- );
- }
-
- return exitCode;
- }
-
- /**
- * 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 in ${
- this.config?.operationMode || "unknown"
- } mode: ${error.message}`
- );
-
- // Log error details
- if (error.stack) {
- await this.logger.error("Stack trace:");
- console.error(error.stack);
- }
-
- if (error.errorHistory) {
- await this.logger.error(
- "Error history available - check logs for retry attempts"
- );
- }
-
- // Ensure progress logging continues even for unexpected errors
- // Use appropriate summary format based on operation mode
- try {
- if (this.config?.operationMode === "rollback") {
- const summary = {
- totalProducts: operationResults?.totalProducts || 0,
- totalVariants: operationResults?.totalVariants || 0,
- eligibleVariants: operationResults?.eligibleVariants || 0,
- successfulRollbacks: operationResults?.successfulRollbacks || 0,
- failedRollbacks: operationResults?.failedRollbacks || 0,
- skippedVariants: operationResults?.skippedVariants || 0,
- startTime: this.startTime,
- errors: operationResults?.errors || [
- {
- productTitle: "Unexpected System Error",
- productId: "N/A",
- errorMessage: error.message,
- },
- ],
- };
- await this.logger.logRollbackSummary(summary);
- } else {
- const summary = {
- totalProducts: operationResults?.totalProducts || 0,
- totalVariants: operationResults?.totalVariants || 0,
- 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:",
- loggingError.message
- );
- }
- }
-}
-
-/**
- * Main execution function
- * Handles graceful exit with appropriate status codes
- */
-async function main() {
- const app = new ShopifyPriceUpdater();
-
- // Enhanced signal handling state management
- let schedulingActive = false;
- let operationInProgress = false;
- let signalHandlersSetup = false;
-
- /**
- * Enhanced signal handler that coordinates with scheduling and operation states
- * @param {string} signal - The signal received (SIGINT, SIGTERM)
- * @param {number} exitCode - Exit code to use
- */
- const handleShutdown = async (signal, exitCode) => {
- // During scheduled waiting period - provide clear cancellation message
- if (schedulingActive && !operationInProgress) {
- console.log(`\n🛑 Received ${signal} during scheduled wait period.`);
- console.log("📋 Cancelling scheduled operation...");
-
- try {
- // Clean up scheduling resources
- if (app.scheduleService) {
- app.scheduleService.cleanup();
- }
-
- // Log cancellation to progress file
- const logger = new Logger();
- await logger.warning(
- `Scheduled operation cancelled by ${signal} signal`
- );
- console.log(
- "✅ Scheduled operation cancelled successfully. No price updates were performed."
- );
- } catch (error) {
- console.error("Failed to log cancellation:", error.message);
- }
-
- process.exit(0); // Clean cancellation, exit with success
- return;
- }
-
- // During active price update operations - prevent interruption
- if (operationInProgress) {
- console.log(
- `\n⚠️ Received ${signal} during active price update operations.`
- );
- console.log(
- "🔒 Cannot cancel while price updates are in progress to prevent data corruption."
- );
- console.log("⏳ Please wait for current operations to complete...");
- console.log(
- "💡 Tip: You can cancel during the countdown period before operations begin."
- );
- return; // Do not exit, let operations complete
- }
-
- // Normal shutdown for non-scheduled operations or after operations complete
- console.log(`\n🛑 Received ${signal}. Shutting down gracefully...`);
- try {
- // Clean up scheduling resources
- if (app.scheduleService) {
- app.scheduleService.cleanup();
- }
-
- // Attempt to log shutdown to progress file
- const logger = new Logger();
- await logger.warning(`Operation interrupted by ${signal}`);
- } catch (error) {
- console.error("Failed to log shutdown:", error.message);
- }
- process.exit(exitCode);
- };
-
- /**
- * Set up enhanced signal handlers with proper coordination
- */
- const setupSignalHandlers = () => {
- if (signalHandlersSetup) {
- return; // Avoid duplicate handlers
- }
-
- process.on("SIGINT", () => handleShutdown("SIGINT", 130));
- process.on("SIGTERM", () => handleShutdown("SIGTERM", 143));
- signalHandlersSetup = true;
- };
-
- /**
- * Update scheduling state for signal handler coordination
- * @param {boolean} active - Whether scheduling is currently active
- */
- const setSchedulingActive = (active) => {
- schedulingActive = active;
- };
-
- /**
- * Update operation state for signal handler coordination
- * @param {boolean} inProgress - Whether price update operations are in progress
- */
- const setOperationInProgress = (inProgress) => {
- operationInProgress = inProgress;
- };
-
- // Make state management functions available to the app
- app.setSchedulingActive = setSchedulingActive;
- app.setOperationInProgress = setOperationInProgress;
-
- // Set up enhanced signal handlers
- setupSignalHandlers();
-
- // Handle unhandled promise rejections with enhanced logging
- process.on("unhandledRejection", async (reason, promise) => {
- console.error("🚨 Unhandled Promise Rejection detected:");
- console.error("Promise:", promise);
- console.error("Reason:", reason);
-
- try {
- // Attempt to log to progress file
- const logger = new Logger();
- await logger.error(`Unhandled Promise Rejection: ${reason}`);
- } catch (error) {
- console.error("Failed to log unhandled rejection:", error.message);
- }
-
- process.exit(1);
- });
-
- // Handle uncaught exceptions with enhanced logging
- process.on("uncaughtException", async (error) => {
- console.error("🚨 Uncaught Exception detected:");
- console.error("Error:", error.message);
- console.error("Stack:", error.stack);
-
- try {
- // Attempt to log to progress file
- const logger = new Logger();
- await logger.error(`Uncaught Exception: ${error.message}`);
- } catch (loggingError) {
- console.error("Failed to log uncaught exception:", loggingError.message);
- }
-
- process.exit(1);
- });
-
- try {
- // Check if scheduling is active to coordinate signal handling
- const { getConfig } = require("./config/environment");
- const config = getConfig();
-
- // Set initial scheduling state
- if (config.isScheduled) {
- setSchedulingActive(true);
- }
-
- const exitCode = await app.run();
-
- // Clear all states after run completes
- setSchedulingActive(false);
- setOperationInProgress(false);
-
- process.exit(exitCode);
- } catch (error) {
- console.error("Fatal error:", error.message);
-
- // Clean up scheduling resources on error
- if (app.scheduleService) {
- app.scheduleService.cleanup();
- }
-
- // Clear states on error
- setSchedulingActive(false);
- setOperationInProgress(false);
-
- process.exit(2);
- }
-}
-
-// Only run main if this file is executed directly
-if (require.main === module) {
- main();
-}
-
-module.exports = ShopifyPriceUpdater;
-
-
-
-src\tui-entry.js:
-
-#!/usr/bin/env node
-
-/**
- * TUI Entry Point
- * Initializes the Ink-based Terminal User Interface
- * Requirements: 2.2, 2.5
- */
-
-const React = require("react");
-const { render } = require("ink");
-const TuiApplication = require("./tui/TuiApplication.jsx");
-
-// Initialize the TUI application
-const main = () => {
- try {
- // Render the main TUI application
- const { waitUntilExit } = render(React.createElement(TuiApplication));
-
- // Wait for the application to exit
- return waitUntilExit();
- } catch (error) {
- console.error("Failed to start TUI application:", error);
- process.exit(1);
- }
-};
-
-// Handle process signals gracefully
-process.on("SIGINT", () => {
- process.exit(0);
-});
-
-process.on("SIGTERM", () => {
- process.exit(0);
-});
-
-// Start the application
-if (require.main === module) {
- main().catch((error) => {
- console.error("TUI application error:", error);
- process.exit(1);
- });
-}
-
-module.exports = main;
-
-
-
-tests\config\environment.test.js:
-
-const { loadEnvironmentConfig } = require("../../src/config/environment");
-
-describe("Environment Configuration", () => {
- // Store original environment variables
- const originalEnv = process.env;
-
- beforeEach(() => {
- // Reset environment variables before each test
- jest.resetModules();
- process.env = { ...originalEnv };
- // Clear the specific environment variables we're testing
- delete process.env.SHOPIFY_SHOP_DOMAIN;
- delete process.env.SHOPIFY_ACCESS_TOKEN;
- delete process.env.TARGET_TAG;
- delete process.env.PRICE_ADJUSTMENT_PERCENTAGE;
- delete process.env.OPERATION_MODE;
- delete process.env.SCHEDULED_EXECUTION_TIME;
- });
-
- afterAll(() => {
- // Restore original environment variables
- process.env = originalEnv;
- });
-
- describe("loadEnvironmentConfig", () => {
- test("should load valid configuration successfully", () => {
- // Set up valid environment 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";
-
- const config = loadEnvironmentConfig();
-
- expect(config).toEqual({
- shopDomain: "test-shop.myshopify.com",
- accessToken: "shpat_1234567890abcdef",
- targetTag: "sale",
- priceAdjustmentPercentage: 10,
- operationMode: "update",
- scheduledExecutionTime: null,
- isScheduled: false,
- });
- });
-
- test("should handle negative percentage correctly", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "clearance";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "-20";
-
- const config = loadEnvironmentConfig();
-
- expect(config.priceAdjustmentPercentage).toBe(-20);
- });
-
- test("should handle decimal percentage correctly", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "premium";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "5.5";
-
- const config = loadEnvironmentConfig();
-
- expect(config.priceAdjustmentPercentage).toBe(5.5);
- });
-
- test("should trim whitespace from string values", () => {
- 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";
-
- const config = loadEnvironmentConfig();
-
- expect(config.shopDomain).toBe("test-shop.myshopify.com");
- expect(config.accessToken).toBe("shpat_1234567890abcdef");
- expect(config.targetTag).toBe("sale");
- });
-
- test("should accept custom domain format", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "custom-domain.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "sale";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- const config = loadEnvironmentConfig();
-
- expect(config.shopDomain).toBe("custom-domain.com");
- });
-
- describe("Missing environment variables", () => {
- test("should throw error when SHOPIFY_SHOP_DOMAIN is missing", () => {
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "sale";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables: SHOPIFY_SHOP_DOMAIN"
- );
- });
-
- test("should throw error when SHOPIFY_ACCESS_TOKEN is missing", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.TARGET_TAG = "sale";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables: SHOPIFY_ACCESS_TOKEN"
- );
- });
-
- test("should throw error when TARGET_TAG is missing", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables: TARGET_TAG"
- );
- });
-
- test("should throw error when PRICE_ADJUSTMENT_PERCENTAGE is missing", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "sale";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables: PRICE_ADJUSTMENT_PERCENTAGE"
- );
- });
-
- test("should throw error when multiple variables are missing", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables: SHOPIFY_ACCESS_TOKEN, TARGET_TAG, PRICE_ADJUSTMENT_PERCENTAGE"
- );
- });
-
- test("should throw error when variables are empty strings", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "";
- process.env.SHOPIFY_ACCESS_TOKEN = "";
- process.env.TARGET_TAG = "";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables"
- );
- });
-
- test("should throw error when variables are whitespace only", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = " ";
- process.env.SHOPIFY_ACCESS_TOKEN = " ";
- process.env.TARGET_TAG = " ";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = " ";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables"
- );
- });
- });
-
- describe("Invalid environment variable values", () => {
- test("should throw error for invalid 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 = "invalid";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- 'Invalid PRICE_ADJUSTMENT_PERCENTAGE: "invalid". Must be a valid number.'
- );
- });
-
- test("should throw error for invalid shop domain", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "invalid-domain";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "sale";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- 'Invalid SHOPIFY_SHOP_DOMAIN: "invalid-domain". Must be a valid Shopify domain'
- );
- });
-
- test("should throw error for short access token", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "short";
- process.env.TARGET_TAG = "sale";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Invalid SHOPIFY_ACCESS_TOKEN: Token appears to be too short"
- );
- });
-
- test("should throw error for whitespace-only target tag", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = " "; // This will be caught by the missing variables check
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Missing required environment variables: TARGET_TAG"
- );
- });
- });
-
- describe("Edge cases", () => {
- test("should handle zero percentage", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "no-change";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "0";
-
- const config = loadEnvironmentConfig();
-
- expect(config.priceAdjustmentPercentage).toBe(0);
- });
-
- test("should handle very large percentage", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "huge-increase";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "1000";
-
- const config = loadEnvironmentConfig();
-
- expect(config.priceAdjustmentPercentage).toBe(1000);
- });
-
- test("should handle very small decimal percentage", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "tiny-adjustment";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "0.01";
-
- const config = loadEnvironmentConfig();
-
- expect(config.priceAdjustmentPercentage).toBe(0.01);
- });
-
- test("should handle tag with special characters", () => {
- process.env.SHOPIFY_SHOP_DOMAIN = "test-shop.myshopify.com";
- process.env.SHOPIFY_ACCESS_TOKEN = "shpat_1234567890abcdef";
- process.env.TARGET_TAG = "sale-2024_special!";
- process.env.PRICE_ADJUSTMENT_PERCENTAGE = "10";
-
- const config = loadEnvironmentConfig();
-
- 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",
- scheduledExecutionTime: null,
- isScheduled: false,
- });
- });
-
- 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);
- });
- });
-
- describe("Scheduled Execution Time", () => {
- beforeEach(() => {
- // Set up valid base environment 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";
- });
-
- test("should handle missing SCHEDULED_EXECUTION_TIME (backward compatibility)", () => {
- // SCHEDULED_EXECUTION_TIME is not set
-
- const config = loadEnvironmentConfig();
-
- expect(config.scheduledExecutionTime).toBeNull();
- expect(config.isScheduled).toBe(false);
- });
-
- test("should handle empty SCHEDULED_EXECUTION_TIME", () => {
- process.env.SCHEDULED_EXECUTION_TIME = "";
-
- const config = loadEnvironmentConfig();
-
- expect(config.scheduledExecutionTime).toBeNull();
- expect(config.isScheduled).toBe(false);
- });
-
- test("should accept valid ISO 8601 datetime with Z timezone", () => {
- const futureTime = "2030-01-15T12:00:00Z";
- process.env.SCHEDULED_EXECUTION_TIME = futureTime;
-
- const config = loadEnvironmentConfig();
-
- expect(config.scheduledExecutionTime).toEqual(new Date(futureTime));
- expect(config.isScheduled).toBe(true);
- });
-
- test("should throw error for invalid datetime format", () => {
- process.env.SCHEDULED_EXECUTION_TIME = "2024-01-15 12:00:00";
-
- expect(() => loadEnvironmentConfig()).toThrow(
- "Invalid SCHEDULED_EXECUTION_TIME format"
- );
- });
-
- test("should throw error for past datetime", () => {
- const pastTime = "2020-01-15T09:00:00Z"; // Clearly in the past
- process.env.SCHEDULED_EXECUTION_TIME = pastTime;
-
- expect(() => loadEnvironmentConfig()).toThrow("must be in the future");
- });
- });
- });
-});
-
-
-
-tests\integration\rollback-workflow.test.js:
-
-/**
- * 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);
- });
- });
-});
-
-
-
-tests\integration\scheduled-execution-workflow.test.js:
-
-/**
- * Integration Tests for Scheduled Execution Workflow
- * These tests verify the complete scheduled execution functionality works together
- * Requirements: 1.2, 2.1, 3.1, 3.2, 5.1, 5.2
- */
-
-const ShopifyPriceUpdater = require("../../src/index");
-const { getConfig } = require("../../src/config/environment");
-
-// Mock external dependencies but test internal integration
-jest.mock("../../src/config/environment");
-jest.mock("../../src/services/shopify");
-jest.mock("../../src/services/progress");
-jest.mock("../../src/utils/logger");
-
-describe("Scheduled Execution Workflow Integration Tests", () => {
- let mockConfig;
- let mockShopifyService;
-
- beforeEach(() => {
- // Mock base configuration
- mockConfig = {
- shopDomain: "test-shop.myshopify.com",
- accessToken: "test-token",
- targetTag: "scheduled-test",
- priceAdjustmentPercentage: 10,
- operationMode: "update",
- scheduledExecutionTime: null,
- isScheduled: false,
- };
-
- // Mock Shopify service responses
- mockShopifyService = {
- testConnection: jest.fn().mockResolvedValue(true),
- executeQuery: jest.fn(),
- executeMutation: jest.fn(),
- executeWithRetry: jest.fn(),
- };
-
- getConfig.mockReturnValue(mockConfig);
-
- // Mock ShopifyService constructor
- const ShopifyService = require("../../src/services/shopify");
- ShopifyService.mockImplementation(() => mockShopifyService);
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe("Backward Compatibility", () => {
- test("should execute immediately when scheduling is not configured", async () => {
- // Configure without scheduling
- mockConfig.scheduledExecutionTime = null;
- mockConfig.isScheduled = false;
- getConfig.mockReturnValue(mockConfig);
-
- // Mock product response
- const mockProductsResponse = {
- products: {
- edges: [
- {
- node: {
- id: "gid://shopify/Product/123",
- title: "Immediate Test Product",
- tags: ["scheduled-test"],
- variants: {
- edges: [
- {
- node: {
- id: "gid://shopify/ProductVariant/456",
- price: "50.00",
- compareAtPrice: null,
- title: "Test Variant",
- },
- },
- ],
- },
- },
- },
- ],
- 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);
-
- const app = new ShopifyPriceUpdater();
- const exitCode = await app.run();
-
- // Verify immediate execution
- expect(exitCode).toBe(0);
-
- // Verify normal workflow was executed
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(2);
- });
-
- test("should maintain rollback functionality without scheduling", async () => {
- // Configure rollback mode without scheduling
- const rollbackConfig = {
- ...mockConfig,
- operationMode: "rollback",
- scheduledExecutionTime: null,
- isScheduled: false,
- };
- getConfig.mockReturnValue(rollbackConfig);
-
- // Mock rollback product response
- const mockProductsResponse = {
- products: {
- edges: [
- {
- node: {
- id: "gid://shopify/Product/123",
- title: "Rollback Test Product",
- tags: ["scheduled-test"],
- variants: {
- edges: [
- {
- node: {
- id: "gid://shopify/ProductVariant/456",
- price: "55.00",
- compareAtPrice: "50.00",
- title: "Test Variant",
- },
- },
- ],
- },
- },
- },
- ],
- pageInfo: { hasNextPage: false, endCursor: null },
- },
- };
-
- const mockRollbackResponse = {
- productVariantsBulkUpdate: {
- productVariants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: "50.00",
- compareAtPrice: null,
- },
- ],
- userErrors: [],
- },
- };
-
- mockShopifyService.executeWithRetry
- .mockResolvedValueOnce(mockProductsResponse)
- .mockResolvedValue(mockRollbackResponse);
-
- const app = new ShopifyPriceUpdater();
- const exitCode = await app.run();
-
- // Verify immediate rollback execution
- expect(exitCode).toBe(0);
-
- // Verify rollback workflow was executed
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(2);
- });
- });
-
- describe("Scheduled Update Workflow", () => {
- test("should execute complete scheduled update workflow successfully", async () => {
- // Set up scheduled execution for immediate execution (past time)
- const scheduledTime = new Date(Date.now() - 1000); // 1 second ago
- const scheduledConfig = {
- ...mockConfig,
- scheduledExecutionTime: scheduledTime,
- isScheduled: true,
- operationMode: "update",
- };
- getConfig.mockReturnValue(scheduledConfig);
-
- // Mock product fetching response
- const mockProductsResponse = {
- products: {
- edges: [
- {
- node: {
- id: "gid://shopify/Product/123",
- title: "Scheduled Test Product",
- tags: ["scheduled-test"],
- variants: {
- edges: [
- {
- node: {
- id: "gid://shopify/ProductVariant/456",
- price: "50.00",
- compareAtPrice: null,
- title: "Test Variant",
- },
- },
- ],
- },
- },
- },
- ],
- pageInfo: {
- hasNextPage: false,
- endCursor: null,
- },
- },
- };
-
- // Mock successful update response
- const mockUpdateResponse = {
- productVariantsBulkUpdate: {
- productVariants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: "55.00",
- compareAtPrice: "50.00",
- },
- ],
- userErrors: [],
- },
- };
-
- mockShopifyService.executeWithRetry
- .mockResolvedValueOnce(mockProductsResponse)
- .mockResolvedValue(mockUpdateResponse);
-
- // Start the application
- const app = new ShopifyPriceUpdater();
- const exitCode = await app.run();
-
- // Verify successful completion
- expect(exitCode).toBe(0);
-
- // Verify Shopify API calls were made
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(2);
- });
-
- test("should execute complete scheduled rollback workflow successfully", async () => {
- // Set up scheduled execution for rollback mode
- const scheduledTime = new Date(Date.now() - 1000); // 1 second ago
- const scheduledConfig = {
- ...mockConfig,
- scheduledExecutionTime: scheduledTime,
- isScheduled: true,
- operationMode: "rollback",
- };
- getConfig.mockReturnValue(scheduledConfig);
-
- // Mock product fetching response for rollback
- const mockProductsResponse = {
- products: {
- edges: [
- {
- node: {
- id: "gid://shopify/Product/123",
- title: "Rollback Test Product",
- tags: ["scheduled-test"],
- variants: {
- edges: [
- {
- node: {
- id: "gid://shopify/ProductVariant/456",
- price: "55.00",
- compareAtPrice: "50.00",
- title: "Test Variant",
- },
- },
- ],
- },
- },
- },
- ],
- pageInfo: {
- hasNextPage: false,
- endCursor: null,
- },
- },
- };
-
- // Mock successful rollback response
- const mockRollbackResponse = {
- productVariantsBulkUpdate: {
- productVariants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: "50.00",
- compareAtPrice: null,
- },
- ],
- userErrors: [],
- },
- };
-
- mockShopifyService.executeWithRetry
- .mockResolvedValueOnce(mockProductsResponse)
- .mockResolvedValue(mockRollbackResponse);
-
- // Start the application
- const app = new ShopifyPriceUpdater();
- const exitCode = await app.run();
-
- // Verify successful completion
- expect(exitCode).toBe(0);
-
- // Verify Shopify API calls were made
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(2);
- });
- });
-
- describe("Cancellation During Countdown", () => {
- test("should handle cancellation during countdown period gracefully", async () => {
- // Set up scheduled execution for future time
- const scheduledTime = new Date(Date.now() + 5000); // 5 seconds in future
- const scheduledConfig = {
- ...mockConfig,
- scheduledExecutionTime: scheduledTime,
- isScheduled: true,
- };
- getConfig.mockReturnValue(scheduledConfig);
-
- // Start the application
- const app = new ShopifyPriceUpdater();
-
- // Set up cancellation state management
- app.setSchedulingActive = jest.fn();
- app.setOperationInProgress = jest.fn();
-
- const runPromise = app.run();
-
- // Simulate cancellation by calling cleanup on schedule service after a short delay
- setTimeout(() => {
- if (app.scheduleService) {
- app.scheduleService.cleanup();
- }
- }, 50);
-
- const exitCode = await runPromise;
-
- // Verify cancellation was handled (should return 0 for clean cancellation)
- expect(exitCode).toBe(0);
-
- // Verify no product operations were executed
- expect(mockShopifyService.executeWithRetry).not.toHaveBeenCalled();
- });
- });
-
- describe("Error Handling in Scheduled Operations", () => {
- test("should handle API connection failures during scheduled execution", async () => {
- // Set up scheduled execution
- const scheduledTime = new Date(Date.now() - 1000); // 1 second ago
- const scheduledConfig = {
- ...mockConfig,
- scheduledExecutionTime: scheduledTime,
- isScheduled: true,
- };
- getConfig.mockReturnValue(scheduledConfig);
-
- // Mock connection failure
- mockShopifyService.testConnection.mockResolvedValue(false);
-
- const app = new ShopifyPriceUpdater();
- const exitCode = await app.run();
-
- // Verify failure handling
- expect(exitCode).toBe(1);
-
- // Verify connection was tested
- expect(mockShopifyService.testConnection).toHaveBeenCalled();
- });
-
- test("should handle product fetching failures during scheduled execution", async () => {
- // Set up scheduled execution
- const scheduledTime = new Date(Date.now() - 1000); // 1 second ago
- const scheduledConfig = {
- ...mockConfig,
- scheduledExecutionTime: scheduledTime,
- isScheduled: true,
- };
- getConfig.mockReturnValue(scheduledConfig);
-
- // Mock product fetching failure
- mockShopifyService.executeWithRetry.mockRejectedValue(
- new Error("GraphQL API error during scheduled execution")
- );
-
- const app = new ShopifyPriceUpdater();
- const exitCode = await app.run();
-
- // Verify failure handling
- expect(exitCode).toBe(1);
-
- // Verify product fetching was attempted
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalled();
- });
- });
-});
-
-
-
-tests\services\product.test.js:
-
-const ProductService = require("../../src/services/product");
-const ShopifyService = require("../../src/services/shopify");
-const Logger = require("../../src/utils/logger");
-
-// Mock dependencies
-jest.mock("../../src/services/shopify");
-jest.mock("../../src/utils/logger");
-
-describe("ProductService Integration Tests", () => {
- let productService;
- let mockShopifyService;
- let mockLogger;
-
- beforeEach(() => {
- // Create mock instances
- mockShopifyService = {
- executeQuery: jest.fn(),
- executeMutation: jest.fn(),
- executeWithRetry: jest.fn(),
- };
-
- mockLogger = {
- info: jest.fn(),
- warning: jest.fn(),
- error: jest.fn(),
- logProductUpdate: jest.fn(),
- logRollbackUpdate: jest.fn(),
- logProductError: jest.fn(),
- };
-
- // Mock constructors
- ShopifyService.mockImplementation(() => mockShopifyService);
- Logger.mockImplementation(() => mockLogger);
-
- productService = new ProductService();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe("GraphQL Query Generation", () => {
- test("should generate correct products by tag query", () => {
- const query = productService.getProductsByTagQuery();
-
- expect(query).toContain("query getProductsByTag");
- expect(query).toContain("$query: String!");
- expect(query).toContain("$first: Int!");
- expect(query).toContain("$after: String");
- expect(query).toContain(
- "products(first: $first, after: $after, query: $query)"
- );
- expect(query).toContain("variants(first: 100)");
- expect(query).toContain("pageInfo");
- expect(query).toContain("hasNextPage");
- expect(query).toContain("endCursor");
- });
-
- test("should generate correct product variant update mutation", () => {
- const mutation = productService.getProductVariantUpdateMutation();
-
- expect(mutation).toContain("mutation productVariantsBulkUpdate");
- expect(mutation).toContain("$productId: ID!");
- expect(mutation).toContain("$variants: [ProductVariantsBulkInput!]!");
- expect(mutation).toContain(
- "productVariantsBulkUpdate(productId: $productId, variants: $variants)"
- );
- expect(mutation).toContain("productVariant");
- expect(mutation).toContain("userErrors");
- expect(mutation).toContain("field");
- expect(mutation).toContain("message");
- });
- });
-
- describe("Product Fetching with Pagination", () => {
- test("should fetch products with single page response", async () => {
- const mockResponse = {
- products: {
- edges: [
- {
- node: {
- id: "gid://shopify/Product/123",
- title: "Test Product 1",
- tags: ["test-tag", "sale"],
- variants: {
- edges: [
- {
- node: {
- id: "gid://shopify/ProductVariant/456",
- price: "29.99",
- compareAtPrice: null,
- title: "Default Title",
- },
- },
- ],
- },
- },
- },
- ],
- pageInfo: {
- hasNextPage: false,
- endCursor: null,
- },
- },
- };
-
- mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
-
- const products = await productService.fetchProductsByTag("test-tag");
-
- expect(products).toHaveLength(1);
- expect(products[0]).toEqual({
- id: "gid://shopify/Product/123",
- title: "Test Product 1",
- tags: ["test-tag", "sale"],
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: 29.99,
- compareAtPrice: null,
- title: "Default Title",
- },
- ],
- });
-
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(1);
- expect(mockLogger.info).toHaveBeenCalledWith(
- "Starting to fetch products with tag: test-tag"
- );
- });
-
- test("should handle multi-page responses with pagination", async () => {
- const firstPageResponse = {
- products: {
- edges: [
- {
- node: {
- id: "gid://shopify/Product/123",
- title: "Product 1",
- tags: ["test-tag"],
- variants: {
- edges: [
- {
- node: {
- id: "gid://shopify/ProductVariant/456",
- price: "19.99",
- compareAtPrice: "24.99",
- title: "Variant 1",
- },
- },
- ],
- },
- },
- },
- ],
- pageInfo: {
- hasNextPage: true,
- endCursor: "eyJsYXN0X2lkIjoxMjN9",
- },
- },
- };
-
- const secondPageResponse = {
- products: {
- edges: [
- {
- node: {
- id: "gid://shopify/Product/789",
- title: "Product 2",
- tags: ["test-tag"],
- variants: {
- edges: [
- {
- node: {
- id: "gid://shopify/ProductVariant/101112",
- price: "39.99",
- compareAtPrice: null,
- title: "Variant 2",
- },
- },
- ],
- },
- },
- },
- ],
- pageInfo: {
- hasNextPage: false,
- endCursor: null,
- },
- },
- };
-
- mockShopifyService.executeWithRetry
- .mockResolvedValueOnce(firstPageResponse)
- .mockResolvedValueOnce(secondPageResponse);
-
- const products = await productService.fetchProductsByTag("test-tag");
-
- expect(products).toHaveLength(2);
- expect(products[0].id).toBe("gid://shopify/Product/123");
- expect(products[1].id).toBe("gid://shopify/Product/789");
-
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(2);
-
- // Check that pagination variables were passed correctly
- const firstCall = mockShopifyService.executeWithRetry.mock.calls[0][0];
- const secondCall = mockShopifyService.executeWithRetry.mock.calls[1][0];
-
- // Execute the functions to check the variables
- await firstCall();
- await secondCall();
-
- expect(mockShopifyService.executeQuery).toHaveBeenNthCalledWith(
- 1,
- expect.any(String),
- {
- query: "tag:test-tag",
- first: 50,
- after: null,
- }
- );
-
- expect(mockShopifyService.executeQuery).toHaveBeenNthCalledWith(
- 2,
- expect.any(String),
- {
- query: "tag:test-tag",
- first: 50,
- after: "eyJsYXN0X2lkIjoxMjN9",
- }
- );
- });
-
- test("should handle empty product response", async () => {
- const mockResponse = {
- products: {
- edges: [],
- pageInfo: {
- hasNextPage: false,
- endCursor: null,
- },
- },
- };
-
- mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
-
- const products = await productService.fetchProductsByTag(
- "nonexistent-tag"
- );
-
- expect(products).toHaveLength(0);
- expect(mockLogger.info).toHaveBeenCalledWith(
- "Successfully fetched 0 products with tag: nonexistent-tag"
- );
- });
-
- test("should handle API errors during product fetching", async () => {
- const apiError = new Error("GraphQL API error: Invalid query");
- mockShopifyService.executeWithRetry.mockRejectedValue(apiError);
-
- await expect(
- productService.fetchProductsByTag("test-tag")
- ).rejects.toThrow(
- "Product fetching failed: GraphQL API error: Invalid query"
- );
-
- expect(mockLogger.error).toHaveBeenCalledWith(
- "Failed to fetch products with tag test-tag: GraphQL API error: Invalid query"
- );
- });
-
- test("should handle invalid response structure", async () => {
- const invalidResponse = {
- // Missing products field
- data: {
- shop: { name: "Test Shop" },
- },
- };
-
- mockShopifyService.executeWithRetry.mockResolvedValue(invalidResponse);
-
- await expect(
- productService.fetchProductsByTag("test-tag")
- ).rejects.toThrow(
- "Product fetching failed: Invalid response structure: missing products field"
- );
- });
- });
-
- describe("Product Validation", () => {
- test("should validate products with valid data", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Valid Product 1",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: 29.99,
- title: "Variant 1",
- },
- {
- id: "gid://shopify/ProductVariant/789",
- price: 39.99,
- title: "Variant 2",
- },
- ],
- },
- {
- id: "gid://shopify/Product/456",
- title: "Valid Product 2",
- variants: [
- {
- id: "gid://shopify/ProductVariant/101112",
- price: 19.99,
- title: "Single Variant",
- },
- ],
- },
- ];
-
- const validProducts = await productService.validateProducts(products);
-
- expect(validProducts).toHaveLength(2);
- expect(validProducts[0].variants).toHaveLength(2);
- expect(validProducts[1].variants).toHaveLength(1);
- expect(mockLogger.info).toHaveBeenCalledWith(
- "Validated 2 products for price updates"
- );
- });
-
- test("should skip products without variants", 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,
- title: "Valid Variant",
- },
- ],
- },
- ];
-
- const validProducts = await productService.validateProducts(products);
-
- expect(validProducts).toHaveLength(1);
- expect(validProducts[0].title).toBe("Product With Variants");
- expect(mockLogger.warning).toHaveBeenCalledWith(
- 'Skipping product "Product Without Variants" - no variants found'
- );
- });
-
- test("should skip variants with invalid prices", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Product With Mixed Variants",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: 29.99,
- title: "Valid Variant",
- },
- {
- id: "gid://shopify/ProductVariant/789",
- price: "invalid",
- title: "Invalid Price Variant",
- },
- {
- id: "gid://shopify/ProductVariant/101112",
- price: -10.0,
- title: "Negative Price Variant",
- },
- {
- id: "gid://shopify/ProductVariant/131415",
- price: NaN,
- title: "NaN Price Variant",
- },
- ],
- },
- ];
-
- const validProducts = await productService.validateProducts(products);
-
- expect(validProducts).toHaveLength(1);
- expect(validProducts[0].variants).toHaveLength(1);
- expect(validProducts[0].variants[0].title).toBe("Valid Variant");
-
- expect(mockLogger.warning).toHaveBeenCalledWith(
- 'Skipping variant "Invalid Price Variant" in product "Product With Mixed Variants" - invalid price: invalid'
- );
- expect(mockLogger.warning).toHaveBeenCalledWith(
- 'Skipping variant "Negative Price Variant" in product "Product With Mixed Variants" - negative price: -10'
- );
- });
-
- test("should skip products with no valid variants", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Product With All Invalid Variants",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: "invalid",
- title: "Invalid Variant 1",
- },
- {
- id: "gid://shopify/ProductVariant/789",
- price: -5.0,
- title: "Invalid Variant 2",
- },
- ],
- },
- ];
-
- const validProducts = await productService.validateProducts(products);
-
- expect(validProducts).toHaveLength(0);
- expect(mockLogger.warning).toHaveBeenCalledWith(
- 'Skipping product "Product With All Invalid Variants" - no variants with valid prices'
- );
- });
- });
-
- describe("Product Summary Statistics", () => {
- test("should calculate correct summary for products", () => {
- const products = [
- {
- variants: [{ price: 10.0 }, { price: 20.0 }],
- },
- {
- variants: [{ price: 5.0 }, { price: 50.0 }, { price: 30.0 }],
- },
- ];
-
- const summary = productService.getProductSummary(products);
-
- expect(summary).toEqual({
- totalProducts: 2,
- totalVariants: 5,
- priceRange: {
- min: 5.0,
- max: 50.0,
- },
- });
- });
-
- test("should handle empty product list", () => {
- const products = [];
- const summary = productService.getProductSummary(products);
-
- expect(summary).toEqual({
- totalProducts: 0,
- totalVariants: 0,
- priceRange: {
- min: 0,
- max: 0,
- },
- });
- });
-
- test("should handle single product with single variant", () => {
- const products = [
- {
- variants: [{ price: 25.99 }],
- },
- ];
-
- const summary = productService.getProductSummary(products);
-
- expect(summary).toEqual({
- totalProducts: 1,
- totalVariants: 1,
- priceRange: {
- min: 25.99,
- max: 25.99,
- },
- });
- });
- });
-
- describe("Single Variant Price Updates", () => {
- test("should update variant price successfully", async () => {
- const mockResponse = {
- productVariantsBulkUpdate: {
- productVariants: [
- {
- id: "gid://shopify/ProductVariant/123",
- price: "32.99",
- compareAtPrice: "29.99",
- },
- ],
- userErrors: [],
- },
- };
-
- mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
-
- const variant = {
- id: "gid://shopify/ProductVariant/123",
- price: 29.99,
- };
-
- const result = await productService.updateVariantPrice(
- variant,
- "gid://shopify/Product/123",
- 32.99,
- 29.99
- );
-
- expect(result.success).toBe(true);
- expect(result.updatedVariant.id).toBe("gid://shopify/ProductVariant/123");
- expect(result.updatedVariant.price).toBe("32.99");
-
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledWith(
- expect.any(Function),
- mockLogger
- );
- });
-
- test("should handle Shopify API user errors", async () => {
- const mockResponse = {
- productVariantsBulkUpdate: {
- productVariants: [],
- userErrors: [
- {
- field: "price",
- message: "Price must be greater than 0",
- },
- {
- field: "compareAtPrice",
- message: "Compare at price must be greater than price",
- },
- ],
- },
- };
-
- mockShopifyService.executeWithRetry.mockResolvedValue(mockResponse);
-
- const variant = {
- id: "gid://shopify/ProductVariant/123",
- price: 0,
- };
-
- const result = await productService.updateVariantPrice(
- variant,
- "gid://shopify/Product/123",
- 0,
- 0
- );
-
- expect(result.success).toBe(false);
- expect(result.error).toContain("Shopify API errors:");
- expect(result.error).toContain("price: Price must be greater than 0");
- expect(result.error).toContain(
- "compareAtPrice: Compare at price must be greater than price"
- );
- });
-
- test("should handle network errors during variant update", async () => {
- const networkError = new Error("Network connection failed");
- mockShopifyService.executeWithRetry.mockRejectedValue(networkError);
-
- const variant = {
- id: "gid://shopify/ProductVariant/123",
- price: 29.99,
- };
-
- const result = await productService.updateVariantPrice(
- variant,
- "gid://shopify/Product/123",
- 32.99,
- 29.99
- );
-
- expect(result.success).toBe(false);
- expect(result.error).toBe("Network connection failed");
- });
- });
-
- describe("Batch Product Price Updates", () => {
- test("should update multiple products successfully", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Product 1",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: 20.0,
- },
- ],
- },
- {
- id: "gid://shopify/Product/789",
- title: "Product 2",
- variants: [
- {
- id: "gid://shopify/ProductVariant/101112",
- price: 30.0,
- },
- {
- id: "gid://shopify/ProductVariant/131415",
- price: 40.0,
- },
- ],
- },
- ];
-
- // Mock successful responses for all variants
- mockShopifyService.executeWithRetry.mockResolvedValue({
- productVariantsBulkUpdate: {
- productVariants: [
- { id: "test", price: "22.00", compareAtPrice: "20.00" },
- ],
- userErrors: [],
- },
- });
-
- // Mock delay function
- jest.spyOn(productService, "delay").mockResolvedValue();
-
- const results = await productService.updateProductPrices(products, 10);
-
- expect(results.totalProducts).toBe(2);
- expect(results.totalVariants).toBe(3);
- expect(results.successfulUpdates).toBe(3);
- expect(results.failedUpdates).toBe(0);
- expect(results.errors).toHaveLength(0);
-
- expect(mockLogger.logProductUpdate).toHaveBeenCalledTimes(3);
- expect(mockShopifyService.executeWithRetry).toHaveBeenCalledTimes(3);
- });
-
- test("should handle mixed success and failure scenarios", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Product 1",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: 20.0,
- },
- {
- id: "gid://shopify/ProductVariant/789",
- price: 30.0,
- },
- ],
- },
- ];
-
- // Mock first call succeeds, second fails
- mockShopifyService.executeWithRetry
- .mockResolvedValueOnce({
- productVariantsBulkUpdate: {
- productVariants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: "22.00",
- compareAtPrice: "20.00",
- },
- ],
- userErrors: [],
- },
- })
- .mockResolvedValueOnce({
- productVariantsBulkUpdate: {
- productVariants: [],
- userErrors: [
- {
- field: "price",
- message: "Invalid price format",
- },
- ],
- },
- });
-
- jest.spyOn(productService, "delay").mockResolvedValue();
-
- const results = await productService.updateProductPrices(products, 10);
-
- expect(results.totalProducts).toBe(1);
- expect(results.totalVariants).toBe(2);
- expect(results.successfulUpdates).toBe(1);
- expect(results.failedUpdates).toBe(1);
- expect(results.errors).toHaveLength(1);
-
- expect(results.errors[0]).toEqual({
- productId: "gid://shopify/Product/123",
- productTitle: "Product 1",
- variantId: "gid://shopify/ProductVariant/789",
- errorMessage: "Shopify API errors: price: Invalid price format",
- });
-
- expect(mockLogger.logProductUpdate).toHaveBeenCalledTimes(1);
- expect(mockLogger.logProductError).toHaveBeenCalledTimes(1);
- });
-
- test("should handle price calculation errors", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Product 1",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: "invalid", // This will cause calculateNewPrice to throw
- },
- ],
- },
- ];
-
- // Mock calculateNewPrice to throw an error
- const { calculateNewPrice } = require("../../src/utils/price");
- jest.mock("../../src/utils/price");
- require("../../src/utils/price").calculateNewPrice = jest
- .fn()
- .mockImplementation(() => {
- throw new Error("Invalid price format");
- });
-
- jest.spyOn(productService, "delay").mockResolvedValue();
-
- const results = await productService.updateProductPrices(products, 10);
-
- expect(results.totalProducts).toBe(1);
- expect(results.totalVariants).toBe(1);
- expect(results.successfulUpdates).toBe(0);
- expect(results.failedUpdates).toBe(1);
- expect(results.errors).toHaveLength(1);
-
- expect(results.errors[0].errorMessage).toContain(
- "Price calculation failed"
- );
- });
-
- 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,
- },
- ],
- }));
-
- mockShopifyService.executeWithRetry.mockResolvedValue({
- productVariantUpdate: {
- productVariant: { id: "test", price: "11.00" },
- userErrors: [],
- },
- });
-
- const delaySpy = jest.spyOn(productService, "delay").mockResolvedValue();
-
- await productService.updateProductPrices(products, 10);
-
- // 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);
- });
- });
-
- describe("Error Scenarios", () => {
- test("should handle executeWithRetry failures gracefully", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Product 1",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: 20.0,
- },
- ],
- },
- ];
-
- const retryError = new Error("Max retries exceeded");
- retryError.errorHistory = [
- { attempt: 1, error: "Rate limit", retryable: true },
- { attempt: 2, error: "Rate limit", retryable: true },
- { attempt: 3, error: "Rate limit", retryable: true },
- ];
-
- mockShopifyService.executeWithRetry.mockRejectedValue(retryError);
- jest.spyOn(productService, "delay").mockResolvedValue();
-
- const results = await productService.updateProductPrices(products, 10);
-
- expect(results.successfulUpdates).toBe(0);
- expect(results.failedUpdates).toBe(1);
- expect(results.errors[0].errorMessage).toContain("Max retries exceeded");
- });
-
- test("should continue processing after individual failures", async () => {
- const products = [
- {
- id: "gid://shopify/Product/123",
- title: "Product 1",
- variants: [
- {
- id: "gid://shopify/ProductVariant/456",
- price: 20.0,
- },
- {
- id: "gid://shopify/ProductVariant/789",
- price: 30.0,
- },
- ],
- },
- ];
-
- // First call fails, second succeeds
- mockShopifyService.executeWithRetry
- .mockRejectedValueOnce(new Error("Network timeout"))
- .mockResolvedValueOnce({
- productVariantsBulkUpdate: {
- productVariants: [
- {
- id: "gid://shopify/ProductVariant/789",
- price: "33.00",
- compareAtPrice: "30.00",
- },
- ],
- userErrors: [],
- },
- });
-
- jest.spyOn(productService, "delay").mockResolvedValue();
-
- const results = await productService.updateProductPrices(products, 10);
-
- expect(results.successfulUpdates).toBe(1);
- expect(results.failedUpdates).toBe(1);
- expect(mockLogger.logProductUpdate).toHaveBeenCalledTimes(1);
- 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)"
- );
- });
- });
- });
-});
-
-
-
-tests\services\progress.test.js:
-
-const ProgressService = require("../../src/services/progress");
-const fs = require("fs").promises;
-const path = require("path");
-
-describe("ProgressService", () => {
- let progressService;
- let testFilePath;
-
- beforeEach(() => {
- // Use a unique test file for each test to avoid conflicts
- testFilePath = `test-progress-${Date.now()}-${Math.random()
- .toString(36)
- .substr(2, 9)}.md`;
- progressService = new ProgressService(testFilePath);
- });
-
- afterEach(async () => {
- // Clean up test file after each test
- try {
- await fs.unlink(testFilePath);
- } catch (error) {
- // File might not exist, that's okay
- }
- });
-
- describe("formatTimestamp", () => {
- test("should format timestamp correctly", () => {
- const testDate = new Date("2024-01-15T14:30:45.123Z");
- const formatted = progressService.formatTimestamp(testDate);
-
- expect(formatted).toBe("2024-01-15 14:30:45 UTC");
- });
-
- test("should use current date when no date provided", () => {
- const formatted = progressService.formatTimestamp();
-
- // Should be a valid timestamp format
- expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
- });
-
- test("should handle different dates correctly", () => {
- const testCases = [
- {
- input: new Date("2023-12-31T23:59:59.999Z"),
- expected: "2023-12-31 23:59:59 UTC",
- },
- {
- input: new Date("2024-01-01T00:00:00.000Z"),
- expected: "2024-01-01 00:00:00 UTC",
- },
- {
- input: new Date("2024-06-15T12:00:00.500Z"),
- expected: "2024-06-15 12:00:00 UTC",
- },
- ];
-
- testCases.forEach(({ input, expected }) => {
- expect(progressService.formatTimestamp(input)).toBe(expected);
- });
- });
- });
-
- describe("logOperationStart", () => {
- test("should create progress file and log operation start", async () => {
- const config = {
- targetTag: "test-tag",
- priceAdjustmentPercentage: 10,
- };
-
- await progressService.logOperationStart(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 Update Operation -");
- expect(content).toContain("Target Tag: test-tag");
- expect(content).toContain("Price Adjustment: 10%");
- expect(content).toContain("**Configuration:**");
- expect(content).toContain("**Progress:**");
- });
-
- test("should handle negative percentage", async () => {
- const config = {
- targetTag: "clearance",
- priceAdjustmentPercentage: -25,
- };
-
- await progressService.logOperationStart(config);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain("Target Tag: clearance");
- expect(content).toContain("Price Adjustment: -25%");
- });
-
- test("should handle special characters in tag", async () => {
- const config = {
- targetTag: "sale-2024_special!",
- priceAdjustmentPercentage: 15.5,
- };
-
- await progressService.logOperationStart(config);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain("Target Tag: sale-2024_special!");
- expect(content).toContain("Price Adjustment: 15.5%");
- });
- });
-
- 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
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const entry = {
- productId: "gid://shopify/Product/123456789",
- productTitle: "Test Product",
- variantId: "gid://shopify/ProductVariant/987654321",
- oldPrice: 29.99,
- newPrice: 32.99,
- };
-
- await progressService.logProductUpdate(entry);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain(
- "✅ **Test Product** (gid://shopify/Product/123456789)"
- );
- expect(content).toContain(
- "Variant: gid://shopify/ProductVariant/987654321"
- );
- expect(content).toContain("Price: $29.99 → $32.99");
- expect(content).toContain("Updated:");
- });
-
- test("should handle products with special characters in title", async () => {
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const entry = {
- productId: "gid://shopify/Product/123",
- productTitle: 'Product with "Quotes" & Special Chars!',
- variantId: "gid://shopify/ProductVariant/456",
- oldPrice: 10.0,
- newPrice: 11.0,
- };
-
- await progressService.logProductUpdate(entry);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain('**Product with "Quotes" & Special Chars!**');
- });
-
- test("should handle decimal prices correctly", async () => {
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 5.5,
- });
-
- const entry = {
- productId: "gid://shopify/Product/123",
- productTitle: "Decimal Price Product",
- variantId: "gid://shopify/ProductVariant/456",
- oldPrice: 19.95,
- newPrice: 21.05,
- };
-
- await progressService.logProductUpdate(entry);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain("Price: $19.95 → $21.05");
- });
- });
-
- 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({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const entry = {
- productId: "gid://shopify/Product/123",
- productTitle: "Failed Product",
- variantId: "gid://shopify/ProductVariant/456",
- errorMessage: "Invalid price data",
- };
-
- await progressService.logError(entry);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain(
- "❌ **Failed Product** (gid://shopify/Product/123)"
- );
- expect(content).toContain("Variant: gid://shopify/ProductVariant/456");
- expect(content).toContain("Error: Invalid price data");
- expect(content).toContain("Failed:");
- });
-
- test("should handle error without variant ID", async () => {
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const entry = {
- productId: "gid://shopify/Product/123",
- productTitle: "Failed Product",
- errorMessage: "Product not found",
- };
-
- await progressService.logError(entry);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain(
- "❌ **Failed Product** (gid://shopify/Product/123)"
- );
- expect(content).not.toContain("Variant:");
- expect(content).toContain("Error: Product not found");
- });
-
- test("should handle complex error messages", async () => {
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const entry = {
- productId: "gid://shopify/Product/123",
- productTitle: "Complex Error Product",
- variantId: "gid://shopify/ProductVariant/456",
- errorMessage:
- "GraphQL error: Field 'price' of type 'Money!' must not be null",
- };
-
- await progressService.logError(entry);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain(
- "Error: GraphQL error: Field 'price' of type 'Money!' must not be null"
- );
- });
- });
-
- describe("logCompletionSummary", () => {
- test("should log completion summary with all statistics", async () => {
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const startTime = new Date(Date.now() - 5000); // 5 seconds ago
- const summary = {
- totalProducts: 10,
- successfulUpdates: 8,
- failedUpdates: 2,
- startTime: startTime,
- };
-
- await progressService.logCompletionSummary(summary);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain("**Summary:**");
- expect(content).toContain("Total Products Processed: 10");
- expect(content).toContain("Successful Updates: 8");
- expect(content).toContain("Failed Updates: 2");
- expect(content).toContain("Duration: 5 seconds");
- expect(content).toContain("Completed:");
- expect(content).toContain("---");
- });
-
- test("should handle summary without start time", async () => {
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const summary = {
- totalProducts: 5,
- successfulUpdates: 5,
- failedUpdates: 0,
- };
-
- await progressService.logCompletionSummary(summary);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain("Duration: Unknown seconds");
- });
-
- test("should handle zero statistics", async () => {
- await progressService.logOperationStart({
- targetTag: "test",
- priceAdjustmentPercentage: 10,
- });
-
- const summary = {
- totalProducts: 0,
- successfulUpdates: 0,
- failedUpdates: 0,
- startTime: new Date(),
- };
-
- await progressService.logCompletionSummary(summary);
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain("Total Products Processed: 0");
- expect(content).toContain("Successful Updates: 0");
- expect(content).toContain("Failed Updates: 0");
- });
- });
-
- 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 = [
- "Rate limit exceeded",
- "HTTP 429 Too Many Requests",
- "Request was throttled",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Rate Limiting"
- );
- });
- });
-
- test("should categorize network errors", () => {
- const testCases = [
- "Network connection failed",
- "Connection timeout",
- "Network error occurred",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Network Issues"
- );
- });
- });
-
- test("should categorize authentication errors", () => {
- const testCases = [
- "Authentication failed",
- "HTTP 401 Unauthorized",
- "Invalid authentication credentials",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Authentication"
- );
- });
- });
-
- test("should categorize permission errors", () => {
- const testCases = [
- "Permission denied",
- "HTTP 403 Forbidden",
- "Insufficient permissions",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Permissions"
- );
- });
- });
-
- test("should categorize not found errors", () => {
- const testCases = [
- "Product not found",
- "HTTP 404 Not Found",
- "Resource not found",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Resource Not Found"
- );
- });
- });
-
- test("should categorize validation errors", () => {
- const testCases = [
- "Validation error: Invalid price",
- "Invalid product data",
- "Price validation failed",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Data Validation"
- );
- });
- });
-
- test("should categorize server errors", () => {
- const testCases = [
- "Internal server error",
- "HTTP 500 Server Error",
- "HTTP 502 Bad Gateway",
- "HTTP 503 Service Unavailable",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Server Errors"
- );
- });
- });
-
- test("should categorize Shopify API errors", () => {
- const testCases = [
- "Shopify API error occurred",
- "Shopify API request failed",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe(
- "Shopify API"
- );
- });
- });
-
- test("should categorize unknown errors as Other", () => {
- const testCases = [
- "Something went wrong",
- "Unexpected error",
- "Random failure message",
- ];
-
- testCases.forEach((errorMessage) => {
- expect(progressService.categorizeError(errorMessage)).toBe("Other");
- });
- });
-
- test("should handle case insensitive categorization", () => {
- expect(progressService.categorizeError("RATE LIMIT EXCEEDED")).toBe(
- "Rate Limiting"
- );
- expect(progressService.categorizeError("Network Connection Failed")).toBe(
- "Network Issues"
- );
- expect(progressService.categorizeError("AUTHENTICATION FAILED")).toBe(
- "Authentication"
- );
- });
- });
-
- describe("createProgressEntry", () => {
- test("should create progress entry with timestamp", () => {
- const data = {
- productId: "gid://shopify/Product/123",
- productTitle: "Test Product",
- status: "success",
- };
-
- const entry = progressService.createProgressEntry(data);
-
- expect(entry).toHaveProperty("timestamp");
- expect(entry.timestamp).toBeInstanceOf(Date);
- expect(entry.productId).toBe("gid://shopify/Product/123");
- expect(entry.productTitle).toBe("Test Product");
- expect(entry.status).toBe("success");
- });
-
- test("should preserve all original data", () => {
- const data = {
- productId: "gid://shopify/Product/456",
- productTitle: "Another Product",
- variantId: "gid://shopify/ProductVariant/789",
- oldPrice: 10.0,
- newPrice: 11.0,
- errorMessage: "Some error",
- };
-
- const entry = progressService.createProgressEntry(data);
-
- expect(entry.productId).toBe(data.productId);
- expect(entry.productTitle).toBe(data.productTitle);
- expect(entry.variantId).toBe(data.variantId);
- expect(entry.oldPrice).toBe(data.oldPrice);
- expect(entry.newPrice).toBe(data.newPrice);
- expect(entry.errorMessage).toBe(data.errorMessage);
- });
- });
-
- describe("appendToProgressFile", () => {
- test("should create file with header when file doesn't exist", async () => {
- await progressService.appendToProgressFile("Test content");
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- expect(content).toContain("# Shopify Price Update Progress Log");
- expect(content).toContain(
- "This file tracks the progress of price update operations."
- );
- expect(content).toContain("Test content");
- });
-
- test("should append to existing file without adding header", async () => {
- // Create file first
- await progressService.appendToProgressFile("First content");
-
- // Append more content
- await progressService.appendToProgressFile("Second content");
-
- const content = await fs.readFile(testFilePath, "utf8");
-
- // Should only have one header
- const headerCount = (
- content.match(/# Shopify Price Update Progress Log/g) || []
- ).length;
- expect(headerCount).toBe(1);
-
- expect(content).toContain("First content");
- expect(content).toContain("Second content");
- });
-
- test("should handle file write errors gracefully", async () => {
- // Mock fs.appendFile to throw an error
- const originalAppendFile = fs.appendFile;
- const mockAppendFile = jest
- .fn()
- .mockRejectedValue(new Error("Permission denied"));
- fs.appendFile = mockAppendFile;
-
- // Should not throw an error, but should log warnings
- const consoleSpy = jest
- .spyOn(console, "warn")
- .mockImplementation(() => {});
-
- await progressService.appendToProgressFile("Test content");
-
- expect(consoleSpy).toHaveBeenCalledWith(
- expect.stringContaining("Warning: Failed to write to progress file")
- );
-
- consoleSpy.mockRestore();
- fs.appendFile = originalAppendFile;
- });
- });
-});
-
-
-
-tests\services\schedule-error-handling.test.js:
-
-/**
- * Error Handling Tests for Scheduling Edge Cases
- * Tests Requirements 1.4, 4.3, 4.4, 5.3 from the scheduled-price-updates spec
- *
- * This test file focuses specifically on edge cases and error scenarios
- * that might not be covered in the main schedule service tests.
- */
-
-const ScheduleService = require("../../src/services/schedule");
-const Logger = require("../../src/utils/logger");
-
-// Mock logger to avoid file operations during tests
-jest.mock("../../src/utils/logger");
-
-describe("ScheduleService Error Handling Edge Cases", () => {
- let scheduleService;
- let mockLogger;
- let consoleSpy;
-
- beforeEach(() => {
- // Mock logger
- mockLogger = {
- info: jest.fn().mockResolvedValue(),
- warning: jest.fn().mockResolvedValue(),
- error: jest.fn().mockResolvedValue(),
- };
- Logger.mockImplementation(() => mockLogger);
-
- scheduleService = new ScheduleService(mockLogger);
-
- // Mock console methods to capture output
- consoleSpy = {
- warn: jest.spyOn(console, "warn").mockImplementation(),
- error: jest.spyOn(console, "error").mockImplementation(),
- log: jest.spyOn(console, "log").mockImplementation(),
- };
-
- // Clear any existing timers
- jest.clearAllTimers();
- jest.useFakeTimers();
- });
-
- afterEach(() => {
- // Clean up schedule service
- scheduleService.cleanup();
- jest.useRealTimers();
- jest.clearAllMocks();
-
- // Restore console methods
- Object.values(consoleSpy).forEach((spy) => spy.mockRestore());
- });
-
- describe("Invalid DateTime Format Edge Cases - Requirement 1.4", () => {
- test("should handle malformed ISO 8601 with extra characters", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25T10:30:00EXTRA");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should handle ISO 8601 with invalid timezone format", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25T10:30:00+25:00");
- }).toThrow(/❌ Invalid datetime values/);
- });
-
- test("should handle datetime with missing leading zeros", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-1-5T9:30:0");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should handle datetime with wrong number of digits", () => {
- expect(() => {
- scheduleService.parseScheduledTime("24-12-25T10:30:00");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should provide clear error message for common mistake - space instead of T", () => {
- try {
- scheduleService.parseScheduledTime("2024-12-25 10:30:00");
- } catch (error) {
- expect(error.message).toContain("❌ Invalid datetime format");
- expect(error.message).toContain("Use 'T' to separate date and time");
- }
- });
-
- test("should provide clear error message for date-only input", () => {
- try {
- scheduleService.parseScheduledTime("2024-12-25");
- } catch (error) {
- expect(error.message).toContain("❌ Invalid datetime format");
- expect(error.message).toContain("YYYY-MM-DDTHH:MM:SS");
- }
- });
-
- test("should handle datetime with invalid separators", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024.12.25T10:30:00");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should handle datetime with mixed valid/invalid parts", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25Tinvalid:30:00");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should handle extremely long input strings", () => {
- const longInput = "2024-12-25T10:30:00" + "Z".repeat(1000);
- expect(() => {
- scheduleService.parseScheduledTime(longInput);
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should handle input with control characters", () => {
- // Control characters will be trimmed, so this becomes a past date test
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25T10:30:00\n\r\t");
- }).toThrow(/❌ Scheduled time is in the past/);
- });
- });
-
- describe("Past DateTime Validation Edge Cases - Requirement 4.3", () => {
- test("should provide detailed context for recently past times", () => {
- const recentPast = new Date(Date.now() - 30000)
- .toISOString()
- .slice(0, 19); // 30 seconds ago
-
- try {
- scheduleService.parseScheduledTime(recentPast);
- } catch (error) {
- expect(error.message).toContain("❌ Scheduled time is in the past");
- expect(error.message).toContain("seconds ago");
- }
- });
-
- test("should provide detailed context for distant past times", () => {
- const distantPast = "2020-01-01T10:30:00";
-
- try {
- scheduleService.parseScheduledTime(distantPast);
- } catch (error) {
- expect(error.message).toContain("❌ Scheduled time is in the past");
- expect(error.message).toContain("days ago");
- }
- });
-
- test("should handle edge case of exactly current time", () => {
- // Create a time that's exactly now (within milliseconds)
- const exactlyNow = new Date().toISOString().slice(0, 19);
-
- try {
- scheduleService.parseScheduledTime(exactlyNow);
- } catch (error) {
- expect(error.message).toContain("❌ Scheduled time is in the past");
- }
- });
-
- test("should handle timezone-related past time edge cases", () => {
- // Create a time that might be future in one timezone but past in another
- const ambiguousTime =
- new Date(Date.now() - 60000).toISOString().slice(0, 19) + "+12:00";
-
- try {
- scheduleService.parseScheduledTime(ambiguousTime);
- } catch (error) {
- expect(error.message).toContain("❌ Scheduled time is in the past");
- }
- });
-
- test("should provide helpful suggestions in past time errors", () => {
- const pastTime = "2020-01-01T10:30:00";
-
- try {
- scheduleService.parseScheduledTime(pastTime);
- } catch (error) {
- expect(error.message).toContain("Current time:");
- expect(error.message).toContain("Scheduled time:");
- }
- });
- });
-
- describe("Distant Future Date Warning Edge Cases - Requirement 4.4", () => {
- test("should warn for exactly 7 days and 1 second in future", () => {
- const exactlySevenDaysAndOneSecond = new Date(
- Date.now() + 7 * 24 * 60 * 60 * 1000 + 1000
- )
- .toISOString()
- .slice(0, 19);
-
- scheduleService.parseScheduledTime(exactlySevenDaysAndOneSecond);
-
- expect(consoleSpy.warn).toHaveBeenCalledWith(
- expect.stringContaining("⚠️ WARNING: Distant Future Scheduling")
- );
- });
-
- test("should not warn for exactly 7 days in future", () => {
- // Use 6 days to ensure we're under the 7-day threshold
- const sixDays = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000)
- .toISOString()
- .slice(0, 19);
-
- scheduleService.parseScheduledTime(sixDays);
-
- expect(consoleSpy.warn).not.toHaveBeenCalled();
- });
-
- test("should warn for extremely distant future dates", () => {
- const veryDistantFuture = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
- .toISOString()
- .slice(0, 19);
-
- scheduleService.parseScheduledTime(veryDistantFuture);
-
- expect(consoleSpy.warn).toHaveBeenCalledWith(
- expect.stringContaining("⚠️ WARNING: Distant Future Scheduling")
- );
- // Check for "Days from now" pattern instead of exact number
- expect(consoleSpy.warn).toHaveBeenCalledWith(
- expect.stringContaining("Days from now")
- );
- });
-
- test("should include helpful context in distant future warnings", () => {
- const distantFuture = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
- .toISOString()
- .slice(0, 19);
-
- scheduleService.parseScheduledTime(distantFuture);
-
- expect(consoleSpy.warn).toHaveBeenCalledWith(
- expect.stringContaining("Please verify this is intentional")
- );
- });
-
- test("should handle leap year calculations in distant future warnings", () => {
- // Test with a date that crosses leap year boundaries
- const leapYearFuture = "2028-03-01T10:30:00"; // 2028 is a leap year
-
- scheduleService.parseScheduledTime(leapYearFuture);
-
- // Should still warn if it's more than 7 days away
- if (
- new Date(leapYearFuture).getTime() - Date.now() >
- 7 * 24 * 60 * 60 * 1000
- ) {
- expect(consoleSpy.warn).toHaveBeenCalledWith(
- expect.stringContaining("⚠️ WARNING: Distant Future Scheduling")
- );
- }
- });
- });
-
- describe("System Behavior with Edge Cases - Requirement 5.3", () => {
- test("should handle system clock changes during validation", () => {
- // Test that validation is consistent with current system time
- const futureTime = new Date(Date.now() + 60000)
- .toISOString()
- .slice(0, 19);
-
- // First validation should pass
- const result1 = scheduleService.parseScheduledTime(futureTime);
- expect(result1).toBeInstanceOf(Date);
-
- // Second validation should also pass (same future time)
- const result2 = scheduleService.parseScheduledTime(futureTime);
- expect(result2).toBeInstanceOf(Date);
- expect(result2.getTime()).toBe(result1.getTime());
- });
-
- test("should handle daylight saving time transitions", () => {
- // Test with times around DST transitions using a future date
- // Note: This is a simplified test as actual DST handling depends on system timezone
- const dstTransitionTime = "2026-03-08T02:30:00"; // Future DST transition date
-
- // Should not throw an error for valid DST transition times
- expect(() => {
- scheduleService.parseScheduledTime(dstTransitionTime);
- }).not.toThrow();
- });
-
- test("should handle memory pressure during validation", () => {
- // Test with many rapid validations to simulate memory pressure
- const futureTime = new Date(Date.now() + 60000)
- .toISOString()
- .slice(0, 19);
-
- for (let i = 0; i < 100; i++) {
- const result = scheduleService.parseScheduledTime(futureTime);
- expect(result).toBeInstanceOf(Date);
- }
-
- // Should still work correctly after many operations
- expect(scheduleService.parseScheduledTime(futureTime)).toBeInstanceOf(
- Date
- );
- });
-
- test("should handle concurrent validation attempts", async () => {
- const futureTime = new Date(Date.now() + 60000)
- .toISOString()
- .slice(0, 19);
-
- // Create multiple concurrent validation promises
- const validationPromises = Array.from({ length: 10 }, () =>
- Promise.resolve().then(() =>
- scheduleService.parseScheduledTime(futureTime)
- )
- );
-
- // All should resolve successfully
- const results = await Promise.all(validationPromises);
- results.forEach((result) => {
- expect(result).toBeInstanceOf(Date);
- });
- });
-
- test("should provide consistent error messages across multiple calls", () => {
- const invalidInput = "invalid-datetime";
- let firstError, secondError;
-
- try {
- scheduleService.parseScheduledTime(invalidInput);
- } catch (error) {
- firstError = error.message;
- }
-
- try {
- scheduleService.parseScheduledTime(invalidInput);
- } catch (error) {
- secondError = error.message;
- }
-
- expect(firstError).toBe(secondError);
- expect(firstError).toContain("❌ Invalid datetime format");
- });
- });
-
- describe("Error Message Quality and Clarity", () => {
- test("should provide actionable error messages for common mistakes", () => {
- const commonMistakes = [
- {
- input: "2024-12-25 10:30:00",
- expectedHint: "Use 'T' to separate date and time",
- },
- {
- input: "2024-12-25",
- expectedHint: "YYYY-MM-DDTHH:MM:SS",
- },
- {
- input: "12/25/2024 10:30:00",
- expectedHint: "ISO 8601 format",
- },
- {
- input: "2024-13-25T10:30:00",
- expectedHint: "Month 13 must be 01-12",
- },
- {
- input: "2024-12-32T10:30:00",
- expectedHint: "day is valid for the given month",
- },
- ];
-
- commonMistakes.forEach(({ input, expectedHint }) => {
- try {
- scheduleService.parseScheduledTime(input);
- } catch (error) {
- expect(error.message).toContain(expectedHint);
- }
- });
- });
-
- test("should include examples in error messages", () => {
- try {
- scheduleService.parseScheduledTime("invalid");
- } catch (error) {
- expect(error.message).toContain("e.g.,");
- expect(error.message).toContain("2024-12-25T10:30:00");
- }
- });
-
- test("should provide timezone guidance in error messages", () => {
- try {
- scheduleService.parseScheduledTime("2024-12-25T10:30:00+25:00");
- } catch (error) {
- expect(error.message).toContain("❌ Invalid datetime values");
- expect(error.message).toContain("24-hour format");
- }
- });
- });
-
- describe("Validation Configuration Edge Cases", () => {
- test("should handle null input to validateSchedulingConfiguration", () => {
- const result = scheduleService.validateSchedulingConfiguration(null);
-
- expect(result.isValid).toBe(false);
- expect(result.errorCategory).toBe("missing_input");
- expect(result.validationError).toContain(
- "❌ Scheduled time is required but not provided"
- );
- });
-
- test("should handle undefined input to validateSchedulingConfiguration", () => {
- const result = scheduleService.validateSchedulingConfiguration(undefined);
-
- expect(result.isValid).toBe(false);
- expect(result.errorCategory).toBe("missing_input");
- });
-
- test("should categorize different error types correctly", () => {
- const testCases = [
- { input: "", expectedCategory: "missing_input" },
- { input: " ", expectedCategory: "missing_input" },
- { input: "invalid-format", expectedCategory: "format" },
- { input: "2020-01-01T10:30:00", expectedCategory: "past_time" },
- { input: "2024-13-25T10:30:00", expectedCategory: "invalid_values" },
- ];
-
- testCases.forEach(({ input, expectedCategory }) => {
- const result = scheduleService.validateSchedulingConfiguration(input);
- expect(result.errorCategory).toBe(expectedCategory);
- });
- });
-
- test("should provide appropriate suggestions for each error category", () => {
- const result = scheduleService.validateSchedulingConfiguration("invalid");
-
- expect(result.suggestions).toBeInstanceOf(Array);
- expect(result.suggestions.length).toBeGreaterThan(0);
- expect(result.suggestions[0]).toContain("Use ISO 8601 format");
- });
- });
-
- describe("Additional Edge Cases for Comprehensive Coverage", () => {
- test("should handle very precise future times", () => {
- // Test with millisecond precision
- const preciseTime = new Date(Date.now() + 1000).toISOString();
-
- const result = scheduleService.parseScheduledTime(preciseTime);
- expect(result).toBeInstanceOf(Date);
- });
-
- test("should handle boundary conditions for distant future warnings", () => {
- // Test exactly at the 7-day boundary
- const sevenDaysExact = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
- const sevenDaysString = sevenDaysExact.toISOString().slice(0, 19);
-
- scheduleService.parseScheduledTime(sevenDaysString);
-
- // The warning behavior at exactly 7 days may vary based on implementation
- // This test ensures it doesn't crash
- expect(true).toBe(true);
- });
-
- test("should handle invalid month/day combinations", () => {
- // JavaScript Date constructor auto-corrects invalid dates,
- // so we test with clearly invalid values that won't be auto-corrected
- const invalidCombinations = [
- "2026-13-15T10:30:00", // Invalid month
- "2026-00-15T10:30:00", // Invalid month (0)
- "2026-12-32T10:30:00", // Invalid day for December
- ];
-
- invalidCombinations.forEach((invalidDate) => {
- expect(() => {
- scheduleService.parseScheduledTime(invalidDate);
- }).toThrow(/❌ Invalid datetime values/);
- });
- });
-
- test("should handle edge cases in time validation", () => {
- const timeEdgeCases = [
- "2026-12-25T24:00:00", // Invalid hour
- "2026-12-25T23:60:00", // Invalid minute
- "2026-12-25T23:59:60", // Invalid second
- ];
-
- timeEdgeCases.forEach((invalidTime) => {
- expect(() => {
- scheduleService.parseScheduledTime(invalidTime);
- }).toThrow(/❌ Invalid datetime values/);
- });
- });
-
- test("should handle various timezone formats", () => {
- // Use a far future time to avoid timezone conversion issues
- const futureBase = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours in future
- const timezoneFormats = [
- futureBase.toISOString().slice(0, 19) + "Z",
- futureBase.toISOString().slice(0, 19) + "+00:00",
- // Use timezones that won't make the time go into the past
- new Date(Date.now() + 25 * 60 * 60 * 1000).toISOString().slice(0, 19) +
- "-05:00",
- new Date(Date.now() + 26 * 60 * 60 * 1000).toISOString().slice(0, 19) +
- "+02:00",
- ];
-
- timezoneFormats.forEach((timeWithTz) => {
- const result = scheduleService.parseScheduledTime(timeWithTz);
- expect(result).toBeInstanceOf(Date);
- });
- });
- });
-});
-
-
-
-tests\services\schedule-signal-handling.test.js:
-
-/**
- * Unit tests for enhanced signal handling in scheduled operations
- * Tests Requirements 3.1, 3.2, 3.3 from the scheduled-price-updates spec
- */
-
-const ScheduleService = require("../../src/services/schedule");
-const Logger = require("../../src/utils/logger");
-
-// Mock logger to avoid file operations during tests
-jest.mock("../../src/utils/logger");
-
-describe("Enhanced Signal Handling for Scheduled Operations", () => {
- let scheduleService;
- let mockLogger;
- let originalProcessOn;
- let originalProcessExit;
- let signalHandlers;
-
- beforeEach(() => {
- // Mock logger
- mockLogger = {
- info: jest.fn().mockResolvedValue(),
- warning: jest.fn().mockResolvedValue(),
- error: jest.fn().mockResolvedValue(),
- };
- Logger.mockImplementation(() => mockLogger);
-
- scheduleService = new ScheduleService(mockLogger);
-
- // Mock process methods
- signalHandlers = {};
- originalProcessOn = process.on;
- originalProcessExit = process.exit;
-
- process.on = jest.fn((signal, handler) => {
- signalHandlers[signal] = handler;
- });
- process.exit = jest.fn();
-
- // Clear any existing timers
- jest.clearAllTimers();
- jest.useFakeTimers();
- });
-
- afterEach(() => {
- // Restore original process methods
- process.on = originalProcessOn;
- process.exit = originalProcessExit;
-
- // Clean up schedule service
- scheduleService.cleanup();
-
- jest.useRealTimers();
- jest.clearAllMocks();
- });
-
- describe("Requirement 3.1: Cancellation during wait period", () => {
- test("should support cancellation during scheduled wait period", async () => {
- // Arrange
- const scheduledTime = new Date(Date.now() + 5000); // 5 seconds from now
- let cancelCallbackExecuted = false;
-
- const onCancel = () => {
- cancelCallbackExecuted = true;
- };
-
- // Act
- const waitPromise = scheduleService.waitUntilScheduledTime(
- scheduledTime,
- onCancel
- );
-
- // Simulate cancellation after 1 second
- setTimeout(() => {
- scheduleService.cleanup(); // This triggers cancellation
- }, 1000);
-
- jest.advanceTimersByTime(1000);
-
- const result = await waitPromise;
-
- // Assert
- expect(result).toBe(false);
- expect(cancelCallbackExecuted).toBe(true);
- expect(scheduleService.cancelRequested).toBe(true);
- });
-
- test("should clean up countdown display on cancellation", async () => {
- // Arrange
- const scheduledTime = new Date(Date.now() + 5000);
- const stopCountdownSpy = jest.spyOn(
- scheduleService,
- "stopCountdownDisplay"
- );
-
- // Act
- const waitPromise = scheduleService.waitUntilScheduledTime(
- scheduledTime,
- () => {}
- );
-
- // Advance time slightly to let the cancellation check start
- jest.advanceTimersByTime(150);
-
- scheduleService.cleanup();
-
- // Advance time to trigger cancellation check
- jest.advanceTimersByTime(150);
-
- const result = await waitPromise;
-
- // Assert
- expect(result).toBe(false);
- expect(stopCountdownSpy).toHaveBeenCalled();
- }, 10000);
- });
-
- describe("Requirement 3.2: Clear cancellation confirmation messages", () => {
- test("should provide clear cancellation confirmation through callback", async () => {
- // Arrange
- const scheduledTime = new Date(Date.now() + 3000);
- let cancellationMessage = "";
-
- const onCancel = () => {
- cancellationMessage = "Operation cancelled by user";
- };
-
- // Act
- const waitPromise = scheduleService.waitUntilScheduledTime(
- scheduledTime,
- onCancel
- );
-
- // Advance time slightly to let the cancellation check start
- jest.advanceTimersByTime(150);
-
- scheduleService.cleanup();
-
- // Advance time to trigger cancellation check
- jest.advanceTimersByTime(150);
-
- const result = await waitPromise;
-
- // Assert
- expect(result).toBe(false);
- expect(cancellationMessage).toBe("Operation cancelled by user");
- }, 10000);
-
- test("should clean up resources properly on cancellation", () => {
- // Arrange
- const scheduledTime = new Date(Date.now() + 5000);
- scheduleService.waitUntilScheduledTime(scheduledTime, () => {});
-
- // Act
- scheduleService.cleanup();
-
- // Assert
- expect(scheduleService.cancelRequested).toBe(true);
- expect(scheduleService.countdownInterval).toBe(null);
- expect(scheduleService.currentTimeoutId).toBe(null);
- });
- });
-
- describe("Requirement 3.3: No interruption once operations begin", () => {
- test("should complete wait period when not cancelled", async () => {
- // Arrange
- const scheduledTime = new Date(Date.now() + 2000); // 2 seconds from now
-
- // Act
- const waitPromise = scheduleService.waitUntilScheduledTime(
- scheduledTime,
- () => {}
- );
-
- // Fast-forward time to scheduled time
- jest.advanceTimersByTime(2000);
-
- const result = await waitPromise;
-
- // Assert
- expect(result).toBe(true);
- expect(scheduleService.cancelRequested).toBe(false);
- });
-
- test("should handle immediate execution when scheduled time is now or past", async () => {
- // Arrange
- const scheduledTime = new Date(Date.now() - 1000); // 1 second ago
-
- // Act
- const result = await scheduleService.waitUntilScheduledTime(
- scheduledTime,
- () => {}
- );
-
- // Assert
- expect(result).toBe(true);
- });
-
- test("should not cancel if cleanup is called after timeout completes", async () => {
- // Arrange
- const scheduledTime = new Date(Date.now() + 1000); // 1 second from now
-
- // Act
- const waitPromise = scheduleService.waitUntilScheduledTime(
- scheduledTime,
- () => {}
- );
-
- // Let the timeout complete first
- jest.advanceTimersByTime(1000);
-
- // Then try to cleanup (should not affect the result)
- scheduleService.cleanup();
-
- const result = await waitPromise;
-
- // Assert
- expect(result).toBe(true); // Should still proceed since timeout completed first
- });
- });
-
- describe("Resource management", () => {
- test("should properly initialize and reset state", () => {
- // Assert initial state
- expect(scheduleService.cancelRequested).toBe(false);
- expect(scheduleService.countdownInterval).toBe(null);
- expect(scheduleService.currentTimeoutId).toBe(null);
-
- // Test reset functionality
- scheduleService.cancelRequested = true;
- scheduleService.reset();
-
- expect(scheduleService.cancelRequested).toBe(false);
- expect(scheduleService.countdownInterval).toBe(null);
- expect(scheduleService.currentTimeoutId).toBe(null);
- });
-
- test("should handle multiple cleanup calls safely", () => {
- // Arrange
- const scheduledTime = new Date(Date.now() + 5000);
- scheduleService.waitUntilScheduledTime(scheduledTime, () => {});
-
- // Act - multiple cleanup calls should not throw errors
- expect(() => {
- scheduleService.cleanup();
- scheduleService.cleanup();
- scheduleService.cleanup();
- }).not.toThrow();
-
- // Assert
- expect(scheduleService.cancelRequested).toBe(true);
- });
- });
-
- describe("Integration with main signal handlers", () => {
- test("should coordinate with external signal handling", async () => {
- // This test verifies that the ScheduleService works properly when
- // signal handling is managed externally (as in the main application)
-
- // Arrange
- const scheduledTime = new Date(Date.now() + 3000);
- let externalCancellationTriggered = false;
-
- // Simulate external signal handler calling cleanup
- const simulateExternalSignalHandler = () => {
- externalCancellationTriggered = true;
- scheduleService.cleanup();
- };
-
- // Act
- const waitPromise = scheduleService.waitUntilScheduledTime(
- scheduledTime,
- () => {}
- );
-
- // Simulate external signal after 1 second
- setTimeout(simulateExternalSignalHandler, 1000);
- jest.advanceTimersByTime(1000);
-
- const result = await waitPromise;
-
- // Assert
- expect(result).toBe(false);
- expect(externalCancellationTriggered).toBe(true);
- expect(scheduleService.cancelRequested).toBe(true);
- });
- });
-});
-
-
-
-tests\services\schedule.test.js:
-
-/**
- * Unit tests for ScheduleService functionality
- * Tests Requirements 1.1, 1.4, 3.1, 4.1, 4.2, 4.3 from the scheduled-price-updates spec
- */
-
-const ScheduleService = require("../../src/services/schedule");
-const Logger = require("../../src/utils/logger");
-
-// Mock logger to avoid file operations during tests
-jest.mock("../../src/utils/logger");
-
-describe("ScheduleService", () => {
- let scheduleService;
- let mockLogger;
-
- beforeEach(() => {
- // Mock logger
- mockLogger = {
- info: jest.fn().mockResolvedValue(),
- warning: jest.fn().mockResolvedValue(),
- error: jest.fn().mockResolvedValue(),
- };
- Logger.mockImplementation(() => mockLogger);
-
- scheduleService = new ScheduleService(mockLogger);
-
- // Clear any existing timers
- jest.clearAllTimers();
- jest.useFakeTimers();
- });
-
- afterEach(() => {
- // Clean up schedule service
- scheduleService.cleanup();
- jest.useRealTimers();
- jest.clearAllMocks();
- });
-
- describe("parseScheduledTime - Requirement 1.1, 4.1, 4.2", () => {
- describe("Valid datetime formats", () => {
- test("should parse basic ISO 8601 format", () => {
- const futureTime = new Date(Date.now() + 60000)
- .toISOString()
- .slice(0, 19);
- const result = scheduleService.parseScheduledTime(futureTime);
-
- expect(result).toBeInstanceOf(Date);
- expect(result.getTime()).toBeGreaterThan(Date.now());
- });
-
- test("should parse ISO 8601 with UTC timezone", () => {
- const futureTime = new Date(Date.now() + 60000).toISOString();
- const result = scheduleService.parseScheduledTime(futureTime);
-
- expect(result).toBeInstanceOf(Date);
- expect(result.getTime()).toBeGreaterThan(Date.now());
- });
-
- test("should parse ISO 8601 with positive timezone offset", () => {
- // Create a future time that accounts for timezone offset
- const futureTime =
- new Date(Date.now() + 60000 + 5 * 60 * 60 * 1000)
- .toISOString()
- .slice(0, 19) + "+05:00";
- const result = scheduleService.parseScheduledTime(futureTime);
-
- expect(result).toBeInstanceOf(Date);
- expect(result.getTime()).toBeGreaterThan(Date.now());
- });
-
- test("should parse ISO 8601 with negative timezone offset", () => {
- const futureTime =
- new Date(Date.now() + 60000).toISOString().slice(0, 19) + "-08:00";
- const result = scheduleService.parseScheduledTime(futureTime);
-
- expect(result).toBeInstanceOf(Date);
- expect(result.getTime()).toBeGreaterThan(Date.now());
- });
-
- test("should parse ISO 8601 with milliseconds", () => {
- const futureTime = new Date(Date.now() + 60000).toISOString();
- const result = scheduleService.parseScheduledTime(futureTime);
-
- expect(result).toBeInstanceOf(Date);
- expect(result.getTime()).toBeGreaterThan(Date.now());
- });
-
- test("should handle whitespace around valid datetime", () => {
- const futureTime =
- " " + new Date(Date.now() + 60000).toISOString().slice(0, 19) + " ";
- const result = scheduleService.parseScheduledTime(futureTime);
-
- expect(result).toBeInstanceOf(Date);
- expect(result.getTime()).toBeGreaterThan(Date.now());
- });
- });
-
- describe("Invalid datetime formats - Requirement 1.4", () => {
- test("should throw error for null input", () => {
- expect(() => {
- scheduleService.parseScheduledTime(null);
- }).toThrow(/❌ Scheduled time is required but not provided/);
- });
-
- test("should throw error for undefined input", () => {
- expect(() => {
- scheduleService.parseScheduledTime(undefined);
- }).toThrow(/❌ Scheduled time is required but not provided/);
- });
-
- test("should throw error for empty string", () => {
- expect(() => {
- scheduleService.parseScheduledTime("");
- }).toThrow(/❌ Scheduled time is required but not provided/);
- });
-
- test("should throw error for whitespace-only string", () => {
- expect(() => {
- scheduleService.parseScheduledTime(" ");
- }).toThrow(/❌ Scheduled time cannot be empty/);
- });
-
- test("should throw error for non-string input", () => {
- expect(() => {
- scheduleService.parseScheduledTime(123);
- }).toThrow(/❌ Scheduled time must be provided as a string/);
- });
-
- test("should throw error for invalid format - space instead of T", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25 10:30:00");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should throw error for invalid format - missing time", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should throw error for invalid format - wrong separator", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024/12/25T10:30:00");
- }).toThrow(/❌ Invalid datetime format/);
- });
-
- test("should throw error for invalid month value", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-13-25T10:30:00");
- }).toThrow(/❌ Invalid datetime values/);
- });
-
- test("should throw error for invalid day value", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-32T10:30:00");
- }).toThrow(/❌ Invalid datetime values/);
- });
-
- test("should throw error for invalid hour value", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25T25:30:00");
- }).toThrow(/❌ Invalid datetime values/);
- });
-
- test("should throw error for invalid minute value", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25T10:60:00");
- }).toThrow(/❌ Invalid datetime values/);
- });
-
- test("should throw error for invalid second value", () => {
- expect(() => {
- scheduleService.parseScheduledTime("2024-12-25T10:30:60");
- }).toThrow(/❌ Invalid datetime values/);
- });
-
- test("should throw error for impossible date", () => {
- // Test with invalid month value instead since JS Date auto-corrects impossible dates
- expect(() => {
- scheduleService.parseScheduledTime("2026-13-15T10:30:00");
- }).toThrow(/❌ Invalid datetime values/);
- });
- });
-
- describe("Past datetime validation - Requirement 4.3", () => {
- test("should throw error for past datetime", () => {
- // Use a clearly past date
- const pastTime = "2020-01-01T10:30:00";
-
- expect(() => {
- scheduleService.parseScheduledTime(pastTime);
- }).toThrow(/❌ Scheduled time is in the past/);
- });
-
- test("should throw error for current time", () => {
- // Use a clearly past date
- const pastTime = "2020-01-01T10:30:00";
-
- expect(() => {
- scheduleService.parseScheduledTime(pastTime);
- }).toThrow(/❌ Scheduled time is in the past/);
- });
-
- test("should include helpful context in past time error", () => {
- // Use a clearly past date
- const pastTime = "2020-01-01T10:30:00";
-
- expect(() => {
- scheduleService.parseScheduledTime(pastTime);
- }).toThrow(/days ago/);
- });
- });
-
- describe("Distant future validation - Requirement 4.4", () => {
- test("should warn for dates more than 7 days in future", () => {
- const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
- const distantFuture = new Date(Date.now() + 8 * 24 * 60 * 60 * 1000)
- .toISOString()
- .slice(0, 19);
-
- const result = scheduleService.parseScheduledTime(distantFuture);
-
- expect(result).toBeInstanceOf(Date);
- expect(consoleSpy).toHaveBeenCalledWith(
- expect.stringContaining("⚠️ WARNING: Distant Future Scheduling")
- );
-
- consoleSpy.mockRestore();
- });
-
- test("should not warn for dates within 7 days", () => {
- const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
- const nearFuture = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000)
- .toISOString()
- .slice(0, 19);
-
- const result = scheduleService.parseScheduledTime(nearFuture);
-
- expect(result).toBeInstanceOf(Date);
- expect(consoleSpy).not.toHaveBeenCalled();
-
- consoleSpy.mockRestore();
- });
- });
- });
-
- describe("calculateDelay - Delay calculation accuracy", () => {
- test("should calculate correct delay for future time", () => {
- const futureTime = new Date(Date.now() + 5000); // 5 seconds from now
- const delay = scheduleService.calculateDelay(futureTime);
-
- expect(delay).toBeGreaterThan(4900);
- expect(delay).toBeLessThan(5100);
- });
-
- test("should return 0 for past time", () => {
- const pastTime = new Date(Date.now() - 1000);
- const delay = scheduleService.calculateDelay(pastTime);
-
- expect(delay).toBe(0);
- });
-
- test("should return 0 for current time", () => {
- const currentTime = new Date();
- const delay = scheduleService.calculateDelay(currentTime);
-
- expect(delay).toBe(0);
- });
-
- test("should handle large delays correctly", () => {
- const distantFuture = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
- const delay = scheduleService.calculateDelay(distantFuture);
-
- expect(delay).toBeGreaterThan(24 * 60 * 60 * 1000 - 1000);
- expect(delay).toBeLessThan(24 * 60 * 60 * 1000 + 1000);
- });
-
- test("should handle edge case of exactly current time", () => {
- const exactTime = new Date(Date.now());
- const delay = scheduleService.calculateDelay(exactTime);
-
- expect(delay).toBeGreaterThanOrEqual(0);
- expect(delay).toBeLessThan(100); // Should be very small
- });
- });
-
- describe("formatTimeRemaining", () => {
- test("should format seconds correctly", () => {
- expect(scheduleService.formatTimeRemaining(30000)).toBe("30s");
- });
-
- test("should format minutes and seconds", () => {
- expect(scheduleService.formatTimeRemaining(90000)).toBe("1m 30s");
- });
-
- test("should format hours, minutes, and seconds", () => {
- expect(scheduleService.formatTimeRemaining(3690000)).toBe("1h 1m 30s");
- });
-
- test("should format days, hours, minutes, and seconds", () => {
- expect(scheduleService.formatTimeRemaining(90090000)).toBe(
- "1d 1h 1m 30s"
- );
- });
-
- test("should handle zero time", () => {
- expect(scheduleService.formatTimeRemaining(0)).toBe("0s");
- });
-
- test("should handle negative time", () => {
- expect(scheduleService.formatTimeRemaining(-1000)).toBe("0s");
- });
-
- test("should format only relevant units", () => {
- expect(scheduleService.formatTimeRemaining(3600000)).toBe("1h");
- expect(scheduleService.formatTimeRemaining(60000)).toBe("1m");
- });
- });
-
- describe("waitUntilScheduledTime - Cancellation handling - Requirement 3.1", () => {
- test("should resolve immediately for past time", async () => {
- const pastTime = new Date(Date.now() - 1000);
- const result = await scheduleService.waitUntilScheduledTime(
- pastTime,
- () => {}
- );
-
- expect(result).toBe(true);
- });
-
- test("should resolve true when timeout completes", async () => {
- const futureTime = new Date(Date.now() + 1000);
-
- const waitPromise = scheduleService.waitUntilScheduledTime(
- futureTime,
- () => {}
- );
-
- jest.advanceTimersByTime(1000);
-
- const result = await waitPromise;
- expect(result).toBe(true);
- });
-
- test("should resolve false when cancelled", async () => {
- const futureTime = new Date(Date.now() + 5000);
- let cancelCallbackCalled = false;
-
- const onCancel = () => {
- cancelCallbackCalled = true;
- };
-
- const waitPromise = scheduleService.waitUntilScheduledTime(
- futureTime,
- onCancel
- );
-
- // Advance time slightly to let cancellation check start
- jest.advanceTimersByTime(150);
-
- // Cancel the operation
- scheduleService.cleanup();
-
- // Advance time to trigger cancellation check
- jest.advanceTimersByTime(150);
-
- const result = await waitPromise;
-
- expect(result).toBe(false);
- expect(cancelCallbackCalled).toBe(true);
- });
-
- test("should clean up timeout on cancellation", async () => {
- const futureTime = new Date(Date.now() + 5000);
-
- const waitPromise = scheduleService.waitUntilScheduledTime(
- futureTime,
- () => {}
- );
-
- // Advance time slightly
- jest.advanceTimersByTime(150);
-
- // Cancel and verify cleanup
- scheduleService.cleanup();
-
- expect(scheduleService.cancelRequested).toBe(true);
- expect(scheduleService.currentTimeoutId).toBe(null);
-
- jest.advanceTimersByTime(150);
-
- const result = await waitPromise;
- expect(result).toBe(false);
- });
-
- test("should handle cancellation without callback", async () => {
- const futureTime = new Date(Date.now() + 2000);
-
- const waitPromise = scheduleService.waitUntilScheduledTime(futureTime);
-
- jest.advanceTimersByTime(150);
- scheduleService.cleanup();
- jest.advanceTimersByTime(150);
-
- const result = await waitPromise;
- expect(result).toBe(false);
- });
-
- test("should not execute callback if timeout completes first", async () => {
- const futureTime = new Date(Date.now() + 1000);
- let cancelCallbackCalled = false;
-
- const onCancel = () => {
- cancelCallbackCalled = true;
- };
-
- const waitPromise = scheduleService.waitUntilScheduledTime(
- futureTime,
- onCancel
- );
-
- // Let timeout complete first
- jest.advanceTimersByTime(1000);
-
- // Then try to cancel (should not affect result)
- scheduleService.cleanup();
-
- const result = await waitPromise;
-
- expect(result).toBe(true);
- expect(cancelCallbackCalled).toBe(false);
- });
- });
-
- describe("displayScheduleInfo", () => {
- test("should display scheduling information", async () => {
- const futureTime = new Date(Date.now() + 60000);
-
- await scheduleService.displayScheduleInfo(futureTime);
-
- expect(mockLogger.info).toHaveBeenCalledWith(
- expect.stringContaining("Operation scheduled for:")
- );
- expect(mockLogger.info).toHaveBeenCalledWith(
- expect.stringContaining("Time remaining:")
- );
- expect(mockLogger.info).toHaveBeenCalledWith(
- "Press Ctrl+C to cancel the scheduled operation"
- );
- });
-
- test("should start countdown display", async () => {
- const futureTime = new Date(Date.now() + 60000);
- const startCountdownSpy = jest.spyOn(
- scheduleService,
- "startCountdownDisplay"
- );
-
- await scheduleService.displayScheduleInfo(futureTime);
-
- expect(startCountdownSpy).toHaveBeenCalledWith(futureTime);
- });
- });
-
- describe("executeScheduledOperation", () => {
- test("should execute operation callback successfully", async () => {
- const mockOperation = jest.fn().mockResolvedValue(0);
-
- const result = await scheduleService.executeScheduledOperation(
- mockOperation
- );
-
- expect(mockOperation).toHaveBeenCalled();
- expect(result).toBe(0);
- expect(mockLogger.info).toHaveBeenCalledWith(
- "Executing scheduled operation..."
- );
- expect(mockLogger.info).toHaveBeenCalledWith(
- "Scheduled operation completed successfully"
- );
- });
-
- test("should handle operation callback errors", async () => {
- const mockOperation = jest
- .fn()
- .mockRejectedValue(new Error("Operation failed"));
-
- await expect(
- scheduleService.executeScheduledOperation(mockOperation)
- ).rejects.toThrow("Operation failed");
-
- expect(mockLogger.error).toHaveBeenCalledWith(
- "Scheduled operation failed: Operation failed"
- );
- });
-
- test("should return default exit code when operation returns undefined", async () => {
- const mockOperation = jest.fn().mockResolvedValue(undefined);
-
- const result = await scheduleService.executeScheduledOperation(
- mockOperation
- );
-
- expect(result).toBe(0);
- });
- });
-
- describe("validateSchedulingConfiguration", () => {
- test("should return valid result for correct datetime", () => {
- const futureTime = new Date(Date.now() + 60000)
- .toISOString()
- .slice(0, 19);
-
- const result =
- scheduleService.validateSchedulingConfiguration(futureTime);
-
- expect(result.isValid).toBe(true);
- expect(result.scheduledTime).toBeInstanceOf(Date);
- expect(result.originalInput).toBe(futureTime);
- expect(result.validationError).toBe(null);
- });
-
- test("should return invalid result with error details for bad input", () => {
- const result =
- scheduleService.validateSchedulingConfiguration("invalid-date");
-
- expect(result.isValid).toBe(false);
- expect(result.scheduledTime).toBe(null);
- expect(result.validationError).toContain("❌ Invalid datetime format");
- expect(result.errorCategory).toBe("format");
- expect(result.suggestions).toContain(
- "Use ISO 8601 format: YYYY-MM-DDTHH:MM:SS"
- );
- });
-
- test("should categorize missing input error correctly", () => {
- const result = scheduleService.validateSchedulingConfiguration("");
-
- expect(result.isValid).toBe(false);
- expect(result.errorCategory).toBe("missing_input");
- expect(result.suggestions).toContain(
- "Set the SCHEDULED_EXECUTION_TIME environment variable"
- );
- });
-
- test("should categorize past time error correctly", () => {
- const pastTime = "2020-01-01T10:30:00";
-
- const result = scheduleService.validateSchedulingConfiguration(pastTime);
-
- expect(result.isValid).toBe(false);
- expect(result.errorCategory).toBe("past_time");
- expect(result.suggestions).toContain(
- "Set a future datetime for the scheduled operation"
- );
- });
-
- test("should categorize invalid values error correctly", () => {
- const result = scheduleService.validateSchedulingConfiguration(
- "2024-13-25T10:30:00"
- );
-
- expect(result.isValid).toBe(false);
- expect(result.errorCategory).toBe("invalid_values");
- expect(result.suggestions).toContain(
- "Verify month is 01-12, day is valid for the month"
- );
- });
- });
-
- describe("Resource management and cleanup", () => {
- test("should initialize with correct default state", () => {
- expect(scheduleService.cancelRequested).toBe(false);
- expect(scheduleService.countdownInterval).toBe(null);
- expect(scheduleService.currentTimeoutId).toBe(null);
- });
-
- test("should reset state correctly", () => {
- // Set some state
- scheduleService.cancelRequested = true;
- scheduleService.countdownInterval = setInterval(() => {}, 1000);
- scheduleService.currentTimeoutId = setTimeout(() => {}, 1000);
-
- // Reset
- scheduleService.reset();
-
- expect(scheduleService.cancelRequested).toBe(false);
- expect(scheduleService.countdownInterval).toBe(null);
- expect(scheduleService.currentTimeoutId).toBe(null);
- });
-
- test("should handle multiple cleanup calls safely", () => {
- expect(() => {
- scheduleService.cleanup();
- scheduleService.cleanup();
- scheduleService.cleanup();
- }).not.toThrow();
-
- expect(scheduleService.cancelRequested).toBe(true);
- });
-
- test("should stop countdown display on cleanup", () => {
- const stopCountdownSpy = jest.spyOn(
- scheduleService,
- "stopCountdownDisplay"
- );
-
- scheduleService.cleanup();
-
- expect(stopCountdownSpy).toHaveBeenCalled();
- });
- });
-
- describe("Edge cases and error handling", () => {
- test("should handle timezone edge cases", () => {
- const timeWithTimezone =
- new Date(Date.now() + 60000).toISOString().slice(0, 19) + "+00:00";
-
- const result = scheduleService.parseScheduledTime(timeWithTimezone);
-
- expect(result).toBeInstanceOf(Date);
- expect(result.getTime()).toBeGreaterThan(Date.now());
- });
-
- test("should handle leap year dates", () => {
- // Test February 29th in a future leap year (2028)
- const leapYearDate = "2028-02-29T10:30:00";
-
- // This should not throw an error for a valid leap year date
- expect(() => {
- scheduleService.parseScheduledTime(leapYearDate);
- }).not.toThrow();
- });
-
- test("should reject February 29th in non-leap year", () => {
- // JavaScript Date constructor auto-corrects Feb 29 in non-leap years to March 1
- // So we test with an invalid day value instead
- expect(() => {
- scheduleService.parseScheduledTime("2027-02-32T10:30:00");
- }).toThrow(/❌ Invalid datetime values/);
- });
-
- test("should handle very small delays correctly", () => {
- const nearFuture = new Date(Date.now() + 10); // 10ms from now
- const delay = scheduleService.calculateDelay(nearFuture);
-
- expect(delay).toBeGreaterThanOrEqual(0);
- expect(delay).toBeLessThan(100);
- });
- });
-});
-
-
-
-tests\services\shopify.test.js:
-
-const ShopifyService = require("../../src/services/shopify");
-const { getConfig } = require("../../src/config/environment");
-
-// Mock the environment config
-jest.mock("../../src/config/environment");
-
-describe("ShopifyService Integration Tests", () => {
- let shopifyService;
- let mockConfig;
-
- beforeEach(() => {
- // Mock configuration
- mockConfig = {
- shopDomain: "test-shop.myshopify.com",
- accessToken: "test-access-token",
- targetTag: "test-tag",
- priceAdjustmentPercentage: 10,
- };
-
- getConfig.mockReturnValue(mockConfig);
- shopifyService = new ShopifyService();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe("GraphQL Query Execution", () => {
- test("should execute product query with mock response", async () => {
- const query = `
- query getProductsByTag($tag: String!, $first: Int!, $after: String) {
- products(first: $first, after: $after, query: $tag) {
- edges {
- node {
- id
- title
- tags
- variants(first: 100) {
- edges {
- node {
- id
- price
- compareAtPrice
- }
- }
- }
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- `;
-
- const variables = {
- tag: "tag:test-tag",
- first: 50,
- after: null,
- };
-
- const response = await shopifyService.executeQuery(query, variables);
-
- expect(response).toHaveProperty("products");
- expect(response.products).toHaveProperty("edges");
- expect(response.products).toHaveProperty("pageInfo");
- expect(response.products.pageInfo).toHaveProperty("hasNextPage", false);
- expect(response.products.pageInfo).toHaveProperty("endCursor", null);
- });
-
- test("should handle query with pagination variables", async () => {
- const query = `query getProductsByTag($tag: String!, $first: Int!, $after: String) {
- products(first: $first, after: $after, query: $tag) {
- edges { node { id title } }
- pageInfo { hasNextPage endCursor }
- }
- }`;
-
- const variables = {
- tag: "tag:sale",
- first: 25,
- after: "eyJsYXN0X2lkIjoxMjM0NTY3ODkwfQ==",
- };
-
- const response = await shopifyService.executeQuery(query, variables);
-
- expect(response).toHaveProperty("products");
- expect(Array.isArray(response.products.edges)).toBe(true);
- });
-
- test("should throw error for unsupported query types", async () => {
- const unsupportedQuery = `
- query getShopInfo {
- shop {
- name
- domain
- }
- }
- `;
-
- await expect(
- shopifyService.executeQuery(unsupportedQuery)
- ).rejects.toThrow("Simulated API - Query not implemented");
- });
-
- test("should handle empty query variables", async () => {
- const query = `query getProductsByTag($tag: String!, $first: Int!, $after: String) {
- products(first: $first, after: $after, query: $tag) {
- edges { node { id } }
- }
- }`;
-
- // Should not throw when variables is undefined
- const response = await shopifyService.executeQuery(query);
- expect(response).toHaveProperty("products");
- });
- });
-
- describe("GraphQL Mutation Execution", () => {
- test("should execute product variant update mutation successfully", async () => {
- const mutation = `
- mutation productVariantUpdate($input: ProductVariantInput!) {
- productVariantUpdate(input: $input) {
- productVariant {
- id
- price
- compareAtPrice
- }
- userErrors {
- field
- message
- }
- }
- }
- `;
-
- const variables = {
- input: {
- id: "gid://shopify/ProductVariant/123456789",
- price: "29.99",
- },
- };
-
- const response = await shopifyService.executeMutation(
- mutation,
- variables
- );
-
- expect(response).toHaveProperty("productVariantUpdate");
- expect(response.productVariantUpdate).toHaveProperty("productVariant");
- expect(response.productVariantUpdate).toHaveProperty("userErrors", []);
- expect(response.productVariantUpdate.productVariant.id).toBe(
- variables.input.id
- );
- expect(response.productVariantUpdate.productVariant.price).toBe(
- variables.input.price
- );
- });
-
- test("should handle mutation with compare at price", async () => {
- const mutation = `mutation productVariantUpdate($input: ProductVariantInput!) {
- productVariantUpdate(input: $input) {
- productVariant { id price compareAtPrice }
- userErrors { field message }
- }
- }`;
-
- const variables = {
- input: {
- id: "gid://shopify/ProductVariant/987654321",
- price: "39.99",
- compareAtPrice: "49.99",
- },
- };
-
- const response = await shopifyService.executeMutation(
- mutation,
- variables
- );
-
- expect(response.productVariantUpdate.productVariant.id).toBe(
- variables.input.id
- );
- expect(response.productVariantUpdate.productVariant.price).toBe(
- variables.input.price
- );
- });
-
- test("should throw error for unsupported mutation types", async () => {
- const unsupportedMutation = `
- mutation createProduct($input: ProductInput!) {
- productCreate(input: $input) {
- product { id }
- }
- }
- `;
-
- const variables = {
- input: {
- title: "New Product",
- },
- };
-
- await expect(
- shopifyService.executeMutation(unsupportedMutation, variables)
- ).rejects.toThrow("Simulated API - Mutation not implemented");
- });
-
- test("should handle mutation with empty variables", async () => {
- const mutation = `mutation productVariantUpdate($input: ProductVariantInput!) {
- productVariantUpdate(input: $input) {
- productVariant { id price }
- userErrors { field message }
- }
- }`;
-
- // Should handle when variables is undefined (will cause error accessing variables.input)
- await expect(shopifyService.executeMutation(mutation)).rejects.toThrow(
- "Cannot read properties of undefined"
- );
- });
- });
-
- describe("Rate Limiting and Retry Logic", () => {
- test("should identify rate limiting errors correctly", () => {
- const rateLimitErrors = [
- new Error("HTTP 429 Too Many Requests"),
- new Error("Rate limit exceeded"),
- new Error("Request was throttled"),
- new Error("API rate limit reached"),
- ];
-
- rateLimitErrors.forEach((error) => {
- expect(shopifyService.isRateLimitError(error)).toBe(true);
- });
- });
-
- test("should identify network errors correctly", () => {
- const networkErrors = [
- { code: "ECONNRESET", message: "Connection reset" },
- { code: "ENOTFOUND", message: "Host not found" },
- { code: "ECONNREFUSED", message: "Connection refused" },
- { code: "ETIMEDOUT", message: "Connection timeout" },
- { code: "EAI_AGAIN", message: "DNS lookup failed" },
- new Error("Network connection failed"),
- new Error("Connection timeout occurred"),
- ];
-
- networkErrors.forEach((error) => {
- expect(shopifyService.isNetworkError(error)).toBe(true);
- });
- });
-
- test("should identify server errors correctly", () => {
- const serverErrors = [
- new Error("HTTP 500 Internal Server Error"),
- new Error("HTTP 502 Bad Gateway"),
- new Error("HTTP 503 Service Unavailable"),
- new Error("HTTP 504 Gateway Timeout"),
- new Error("HTTP 505 HTTP Version Not Supported"),
- ];
-
- serverErrors.forEach((error) => {
- expect(shopifyService.isServerError(error)).toBe(true);
- });
- });
-
- test("should identify Shopify temporary errors correctly", () => {
- const shopifyErrors = [
- new Error("Internal server error"),
- new Error("Service unavailable"),
- new Error("Request timeout"),
- new Error("Temporarily unavailable"),
- new Error("Under maintenance"),
- ];
-
- shopifyErrors.forEach((error) => {
- expect(shopifyService.isShopifyTemporaryError(error)).toBe(true);
- });
- });
-
- test("should calculate retry delays with exponential backoff", () => {
- const baseDelay = 1000;
- shopifyService.baseRetryDelay = baseDelay;
-
- // Test standard exponential backoff
- expect(
- shopifyService.calculateRetryDelay(1, new Error("Network error"))
- ).toBe(baseDelay);
- expect(
- shopifyService.calculateRetryDelay(2, new Error("Network error"))
- ).toBe(baseDelay * 2);
- expect(
- shopifyService.calculateRetryDelay(3, new Error("Network error"))
- ).toBe(baseDelay * 4);
-
- // Test rate limit delays (should be doubled)
- expect(
- shopifyService.calculateRetryDelay(1, new Error("Rate limit exceeded"))
- ).toBe(baseDelay * 2);
- expect(shopifyService.calculateRetryDelay(2, new Error("HTTP 429"))).toBe(
- baseDelay * 4
- );
- expect(
- shopifyService.calculateRetryDelay(3, new Error("throttled"))
- ).toBe(baseDelay * 8);
- });
-
- test("should execute operation with retry logic for retryable errors", async () => {
- let attemptCount = 0;
- const mockOperation = jest.fn().mockImplementation(() => {
- attemptCount++;
- if (attemptCount < 3) {
- throw new Error("HTTP 429 Rate limit exceeded");
- }
- return { success: true, attempt: attemptCount };
- });
-
- // Mock sleep to avoid actual delays in tests
- jest.spyOn(shopifyService, "sleep").mockResolvedValue();
-
- const result = await shopifyService.executeWithRetry(mockOperation);
-
- expect(result).toEqual({ success: true, attempt: 3 });
- expect(mockOperation).toHaveBeenCalledTimes(3);
- expect(shopifyService.sleep).toHaveBeenCalledTimes(2); // 2 retries
- });
-
- test("should fail immediately for non-retryable errors", async () => {
- const mockOperation = jest.fn().mockImplementation(() => {
- throw new Error("HTTP 400 Bad Request");
- });
-
- await expect(
- shopifyService.executeWithRetry(mockOperation)
- ).rejects.toThrow("Non-retryable error: HTTP 400 Bad Request");
-
- expect(mockOperation).toHaveBeenCalledTimes(1);
- });
-
- test("should fail after max retries for retryable errors", async () => {
- const mockOperation = jest.fn().mockImplementation(() => {
- throw new Error("HTTP 503 Service Unavailable");
- });
-
- // Mock sleep to avoid actual delays
- jest.spyOn(shopifyService, "sleep").mockResolvedValue();
-
- await expect(
- shopifyService.executeWithRetry(mockOperation)
- ).rejects.toThrow("Operation failed after 3 attempts");
-
- expect(mockOperation).toHaveBeenCalledTimes(3);
- });
-
- test("should include error history in failed operations", async () => {
- const mockOperation = jest.fn().mockImplementation(() => {
- throw new Error("HTTP 500 Internal Server Error");
- });
-
- jest.spyOn(shopifyService, "sleep").mockResolvedValue();
-
- try {
- await shopifyService.executeWithRetry(mockOperation);
- } catch (error) {
- expect(error).toHaveProperty("errorHistory");
- expect(error.errorHistory).toHaveLength(3);
- expect(error).toHaveProperty("totalAttempts", 3);
- expect(error).toHaveProperty("lastError");
-
- // Check error history structure
- error.errorHistory.forEach((historyEntry, index) => {
- expect(historyEntry).toHaveProperty("attempt", index + 1);
- expect(historyEntry).toHaveProperty(
- "error",
- "HTTP 500 Internal Server Error"
- );
- expect(historyEntry).toHaveProperty("timestamp");
- expect(historyEntry).toHaveProperty("retryable", true);
- });
- }
- });
-
- test("should use logger for retry attempts when provided", async () => {
- const mockLogger = {
- logRetryAttempt: jest.fn(),
- error: jest.fn(),
- logRateLimit: jest.fn(),
- };
-
- let attemptCount = 0;
- const mockOperation = jest.fn().mockImplementation(() => {
- attemptCount++;
- if (attemptCount < 2) {
- throw new Error("HTTP 429 Rate limit exceeded");
- }
- return { success: true };
- });
-
- jest.spyOn(shopifyService, "sleep").mockResolvedValue();
-
- await shopifyService.executeWithRetry(mockOperation, mockLogger);
-
- expect(mockLogger.logRetryAttempt).toHaveBeenCalledWith(
- 1,
- 3,
- "HTTP 429 Rate limit exceeded"
- );
- expect(mockLogger.logRateLimit).toHaveBeenCalledWith(2); // 2 seconds delay
- });
-
- test("should handle non-retryable errors with logger", async () => {
- const mockLogger = {
- logRetryAttempt: jest.fn(),
- error: jest.fn(),
- };
-
- const mockOperation = jest.fn().mockImplementation(() => {
- throw new Error("HTTP 400 Bad Request");
- });
-
- try {
- await shopifyService.executeWithRetry(mockOperation, mockLogger);
- } catch (error) {
- expect(mockLogger.error).toHaveBeenCalledWith(
- "Non-retryable error encountered: HTTP 400 Bad Request"
- );
- expect(error).toHaveProperty("errorHistory");
- }
- });
- });
-
- describe("Connection Testing", () => {
- test("should test connection successfully", async () => {
- const result = await shopifyService.testConnection();
- expect(result).toBe(true);
- });
-
- test("should handle connection test failures gracefully", async () => {
- // Mock console.error to avoid test output noise
- const consoleSpy = jest
- .spyOn(console, "error")
- .mockImplementation(() => {});
-
- // Override the testConnection method to simulate failure
- shopifyService.testConnection = jest.fn().mockImplementation(async () => {
- console.error("Failed to connect to Shopify API: Connection refused");
- return false;
- });
-
- const result = await shopifyService.testConnection();
- expect(result).toBe(false);
-
- consoleSpy.mockRestore();
- });
- });
-
- describe("API Call Limit Information", () => {
- test("should handle API call limit info when not available", async () => {
- const result = await shopifyService.getApiCallLimit();
- expect(result).toBeNull();
- });
-
- test("should handle API call limit errors gracefully", async () => {
- // Mock console.warn to avoid test output noise
- const consoleSpy = jest
- .spyOn(console, "warn")
- .mockImplementation(() => {});
-
- // Override method to simulate error
- shopifyService.getApiCallLimit = jest
- .fn()
- .mockImplementation(async () => {
- console.warn(
- "Could not retrieve API call limit info: API not initialized"
- );
- return null;
- });
-
- const result = await shopifyService.getApiCallLimit();
- expect(result).toBeNull();
-
- consoleSpy.mockRestore();
- });
- });
-
- describe("Error Classification", () => {
- test("should correctly classify retryable vs non-retryable errors", () => {
- const retryableErrors = [
- new Error("HTTP 429 Too Many Requests"),
- new Error("HTTP 500 Internal Server Error"),
- new Error("HTTP 502 Bad Gateway"),
- new Error("HTTP 503 Service Unavailable"),
- { code: "ECONNRESET", message: "Connection reset" },
- { code: "ETIMEDOUT", message: "Timeout" },
- new Error("Service temporarily unavailable"),
- ];
-
- const nonRetryableErrors = [
- new Error("HTTP 400 Bad Request"),
- new Error("HTTP 401 Unauthorized"),
- new Error("HTTP 403 Forbidden"),
- new Error("HTTP 404 Not Found"),
- new Error("Invalid input data"),
- new Error("Validation failed"),
- ];
-
- retryableErrors.forEach((error) => {
- expect(shopifyService.isRetryableError(error)).toBe(true);
- });
-
- nonRetryableErrors.forEach((error) => {
- expect(shopifyService.isRetryableError(error)).toBe(false);
- });
- });
- });
-
- describe("Sleep Utility", () => {
- test("should sleep for specified duration", async () => {
- const startTime = Date.now();
- await shopifyService.sleep(100); // 100ms
- const endTime = Date.now();
-
- // Allow for some variance in timing
- expect(endTime - startTime).toBeGreaterThanOrEqual(90);
- expect(endTime - startTime).toBeLessThan(200);
- });
-
- test("should handle zero sleep duration", async () => {
- const startTime = Date.now();
- await shopifyService.sleep(0);
- const endTime = Date.now();
-
- expect(endTime - startTime).toBeLessThan(50);
- });
- });
-});
-
-
-
-tests\utils\logger.test.js:
-
-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")
- );
- });
- });
- });
-});
-
-
-
-tests\utils\price.test.js:
-
-const {
- calculateNewPrice,
- isValidPrice,
- formatPrice,
- calculatePercentageChange,
- isValidPercentage,
- preparePriceUpdate,
- validateRollbackEligibility,
- prepareRollbackUpdate,
-} = require("../../src/utils/price");
-
-describe("Price Utilities", () => {
- describe("calculateNewPrice", () => {
- test("should calculate price increase correctly", () => {
- expect(calculateNewPrice(100, 10)).toBe(110);
- expect(calculateNewPrice(50, 20)).toBe(60);
- expect(calculateNewPrice(29.99, 5.5)).toBe(31.64);
- });
-
- test("should calculate price decrease correctly", () => {
- expect(calculateNewPrice(100, -10)).toBe(90);
- expect(calculateNewPrice(50, -20)).toBe(40);
- expect(calculateNewPrice(29.99, -5.5)).toBe(28.34);
- });
-
- test("should handle zero percentage change", () => {
- expect(calculateNewPrice(100, 0)).toBe(100);
- expect(calculateNewPrice(29.99, 0)).toBe(29.99);
- });
-
- test("should handle zero price", () => {
- expect(calculateNewPrice(0, 10)).toBe(0);
- expect(calculateNewPrice(0, -10)).toBe(0);
- expect(calculateNewPrice(0, 0)).toBe(0);
- });
-
- test("should round to 2 decimal places", () => {
- expect(calculateNewPrice(10.005, 10)).toBe(11.01);
- expect(calculateNewPrice(10.004, 10)).toBe(11.0);
- expect(calculateNewPrice(33.333, 10)).toBe(36.67);
- });
-
- test("should handle decimal percentages", () => {
- expect(calculateNewPrice(100, 5.5)).toBe(105.5);
- expect(calculateNewPrice(100, -2.25)).toBe(97.75);
- });
-
- test("should throw error for invalid original price", () => {
- expect(() => calculateNewPrice("invalid", 10)).toThrow(
- "Original price must be a valid number"
- );
- expect(() => calculateNewPrice(NaN, 10)).toThrow(
- "Original price must be a valid number"
- );
- expect(() => calculateNewPrice(null, 10)).toThrow(
- "Original price must be a valid number"
- );
- expect(() => calculateNewPrice(undefined, 10)).toThrow(
- "Original price must be a valid number"
- );
- });
-
- test("should throw error for invalid percentage", () => {
- expect(() => calculateNewPrice(100, "invalid")).toThrow(
- "Percentage must be a valid number"
- );
- expect(() => calculateNewPrice(100, NaN)).toThrow(
- "Percentage must be a valid number"
- );
- expect(() => calculateNewPrice(100, null)).toThrow(
- "Percentage must be a valid number"
- );
- expect(() => calculateNewPrice(100, undefined)).toThrow(
- "Percentage must be a valid number"
- );
- });
-
- test("should throw error for negative original price", () => {
- expect(() => calculateNewPrice(-10, 10)).toThrow(
- "Original price cannot be negative"
- );
- expect(() => calculateNewPrice(-0.01, 5)).toThrow(
- "Original price cannot be negative"
- );
- });
-
- test("should throw error when result would be negative", () => {
- expect(() => calculateNewPrice(10, -150)).toThrow(
- "Price adjustment would result in negative price"
- );
- expect(() => calculateNewPrice(50, -200)).toThrow(
- "Price adjustment would result in negative price"
- );
- });
-
- test("should handle edge case of 100% decrease", () => {
- expect(calculateNewPrice(100, -100)).toBe(0);
- expect(calculateNewPrice(50, -100)).toBe(0);
- });
- });
-
- describe("isValidPrice", () => {
- test("should return true for valid prices", () => {
- expect(isValidPrice(0)).toBe(true);
- expect(isValidPrice(10)).toBe(true);
- expect(isValidPrice(99.99)).toBe(true);
- expect(isValidPrice(1000000)).toBe(true);
- expect(isValidPrice(0.01)).toBe(true);
- });
-
- test("should return false for invalid prices", () => {
- expect(isValidPrice(-1)).toBe(false);
- expect(isValidPrice(-0.01)).toBe(false);
- expect(isValidPrice("10")).toBe(false);
- expect(isValidPrice("invalid")).toBe(false);
- expect(isValidPrice(NaN)).toBe(false);
- expect(isValidPrice(null)).toBe(false);
- expect(isValidPrice(undefined)).toBe(false);
- expect(isValidPrice(Infinity)).toBe(false);
- expect(isValidPrice(-Infinity)).toBe(false);
- });
- });
-
- describe("formatPrice", () => {
- test("should format valid prices correctly", () => {
- expect(formatPrice(10)).toBe("10.00");
- expect(formatPrice(99.99)).toBe("99.99");
- expect(formatPrice(0)).toBe("0.00");
- expect(formatPrice(1000)).toBe("1000.00");
- expect(formatPrice(0.5)).toBe("0.50");
- });
-
- test("should handle prices with more than 2 decimal places", () => {
- expect(formatPrice(10.005)).toBe("10.01");
- expect(formatPrice(10.004)).toBe("10.00");
- expect(formatPrice(99.999)).toBe("100.00");
- });
-
- test("should return 'Invalid Price' for invalid inputs", () => {
- expect(formatPrice(-1)).toBe("Invalid Price");
- expect(formatPrice("invalid")).toBe("Invalid Price");
- expect(formatPrice(NaN)).toBe("Invalid Price");
- expect(formatPrice(null)).toBe("Invalid Price");
- expect(formatPrice(undefined)).toBe("Invalid Price");
- expect(formatPrice(Infinity)).toBe("Invalid Price");
- });
- });
-
- describe("calculatePercentageChange", () => {
- test("should calculate percentage increase correctly", () => {
- expect(calculatePercentageChange(100, 110)).toBe(10);
- expect(calculatePercentageChange(50, 60)).toBe(20);
- expect(calculatePercentageChange(100, 150)).toBe(50);
- });
-
- test("should calculate percentage decrease correctly", () => {
- expect(calculatePercentageChange(100, 90)).toBe(-10);
- expect(calculatePercentageChange(50, 40)).toBe(-20);
- expect(calculatePercentageChange(100, 50)).toBe(-50);
- });
-
- test("should handle no change", () => {
- expect(calculatePercentageChange(100, 100)).toBe(0);
- expect(calculatePercentageChange(50, 50)).toBe(0);
- });
-
- test("should handle zero old price", () => {
- expect(calculatePercentageChange(0, 0)).toBe(0);
- expect(calculatePercentageChange(0, 10)).toBe(Infinity);
- });
-
- test("should round to 2 decimal places", () => {
- expect(calculatePercentageChange(29.99, 31.64)).toBe(5.5);
- expect(calculatePercentageChange(33.33, 36.66)).toBe(9.99);
- });
-
- test("should throw error for invalid prices", () => {
- expect(() => calculatePercentageChange("invalid", 100)).toThrow(
- "Both prices must be valid numbers"
- );
- expect(() => calculatePercentageChange(100, "invalid")).toThrow(
- "Both prices must be valid numbers"
- );
- expect(() => calculatePercentageChange(-10, 100)).toThrow(
- "Both prices must be valid numbers"
- );
- expect(() => calculatePercentageChange(100, -10)).toThrow(
- "Both prices must be valid numbers"
- );
- });
- });
-
- describe("isValidPercentage", () => {
- test("should return true for valid percentages", () => {
- expect(isValidPercentage(0)).toBe(true);
- expect(isValidPercentage(10)).toBe(true);
- expect(isValidPercentage(-10)).toBe(true);
- expect(isValidPercentage(100)).toBe(true);
- expect(isValidPercentage(-100)).toBe(true);
- expect(isValidPercentage(5.5)).toBe(true);
- expect(isValidPercentage(-2.25)).toBe(true);
- expect(isValidPercentage(1000)).toBe(true);
- });
-
- test("should return false for invalid percentages", () => {
- expect(isValidPercentage("10")).toBe(false);
- expect(isValidPercentage("invalid")).toBe(false);
- expect(isValidPercentage(NaN)).toBe(false);
- expect(isValidPercentage(null)).toBe(false);
- expect(isValidPercentage(undefined)).toBe(false);
- expect(isValidPercentage(Infinity)).toBe(false);
- expect(isValidPercentage(-Infinity)).toBe(false);
- });
- });
-
- describe("preparePriceUpdate", () => {
- test("should prepare price update with increase", () => {
- const result = preparePriceUpdate(100, 10);
- expect(result.newPrice).toBe(110);
- expect(result.compareAtPrice).toBe(100);
- });
-
- test("should prepare price update with decrease", () => {
- const result = preparePriceUpdate(100, -20);
- expect(result.newPrice).toBe(80);
- expect(result.compareAtPrice).toBe(100);
- });
-
- test("should prepare price update with zero change", () => {
- const result = preparePriceUpdate(50, 0);
- expect(result.newPrice).toBe(50);
- expect(result.compareAtPrice).toBe(50);
- });
-
- test("should handle decimal prices and percentages", () => {
- const result = preparePriceUpdate(29.99, 5.5);
- expect(result.newPrice).toBe(31.64);
- expect(result.compareAtPrice).toBe(29.99);
- });
-
- test("should throw error for invalid original price", () => {
- expect(() => preparePriceUpdate("invalid", 10)).toThrow(
- "Original price must be a valid number"
- );
- expect(() => preparePriceUpdate(-10, 10)).toThrow(
- "Original price must be a valid number"
- );
- });
-
- test("should throw error for invalid percentage", () => {
- expect(() => preparePriceUpdate(100, "invalid")).toThrow(
- "Percentage must be a valid number"
- );
- expect(() => preparePriceUpdate(100, NaN)).toThrow(
- "Percentage must be a valid number"
- );
- });
-
- test("should throw error when result would be negative", () => {
- expect(() => preparePriceUpdate(10, -150)).toThrow(
- "Price adjustment would result in negative price"
- );
- });
- });
-
- 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"
- );
- });
- });
-});
-
-
-
-tests\index.test.js:
-
-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();
- });
- });
-});
-
-
-
-.env.example:
-
-# Shopify Store Configuration
-SHOPIFY_SHOP_DOMAIN=your-shop-name.myshopify.com
-SHOPIFY_ACCESS_TOKEN=your-admin-api-access-token
-
-# Price Update Configuration
-TARGET_TAG=sale
-
-# Operation Mode Configuration
-# OPERATION_MODE determines whether to update prices or rollback to compare-at prices
-# Options: "update" (default) or "rollback"
-OPERATION_MODE=update
-
-# Optional Configuration
-# PRICE_ADJUSTMENT_PERCENTAGE can be positive (increase) or negative (decrease)
-# Example: 10 = 10% increase, -15 = 15% decrease
-# Note: PRICE_ADJUSTMENT_PERCENTAGE is only used in "update" mode
-PRICE_ADJUSTMENT_PERCENTAGE=10
-
-# Scheduling Configuration (Optional)
-# SCHEDULED_EXECUTION_TIME allows you to schedule the price update operation for a future time
-# Format: ISO 8601 datetime (YYYY-MM-DDTHH:MM:SS)
-# Examples:
-# SCHEDULED_EXECUTION_TIME=2024-12-25T10:30:00 # Local timezone
-# SCHEDULED_EXECUTION_TIME=2024-12-25T10:30:00Z # UTC timezone
-# SCHEDULED_EXECUTION_TIME=2024-12-25T10:30:00-05:00 # EST timezone
-# Leave commented out or remove to execute immediately (default behavior)
-# SCHEDULED_EXECUTION_TIME=2024-12-25T10:30:00
-
-
-package.json:
-
-{
- "name": "shopify-price-updater",
- "version": "1.0.0",
- "description": "A Node.js script to bulk update Shopify product prices based on tags",
- "main": "src/index.js",
- "scripts": {
- "start": "node src/index.js",
- "tui": "node src/tui-entry.js",
- "update": "set OPERATION_MODE=update && node src/index.js",
- "rollback": "set OPERATION_MODE=rollback && node src/index.js",
- "schedule-update": "set OPERATION_MODE=update && node src/index.js",
- "schedule-rollback": "set OPERATION_MODE=rollback && node src/index.js",
- "debug-tags": "node debug-tags.js",
- "test": "jest"
- },
- "keywords": [
- "shopify",
- "price-updater",
- "graphql",
- "bulk-update"
- ],
- "author": "",
- "license": "MIT",
- "dependencies": {
- "@shopify/shopify-api": "^7.7.0",
- "dotenv": "^16.3.1",
- "node-fetch": "^3.3.2",
- "ink": "^3.2.0",
- "react": "^17.0.2",
- "ink-text-input": "^4.0.3",
- "ink-select-input": "^4.2.2",
- "ink-spinner": "^4.0.3"
- },
- "devDependencies": {
- "jest": "^29.7.0",
- "ink-testing-library": "^2.1.0",
- "@babel/preset-react": "^7.22.0",
- "@babel/preset-env": "^7.22.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
-}
-
-
-README.md:
-
-# Shopify Price Updater
-
-A Node.js script that bulk updates product prices in your Shopify store based on product tags using Shopify's GraphQL Admin API.
-
-## Features
-
-- **Tag-based filtering**: Update prices only for products with specific tags
-- **Percentage-based adjustments**: Increase or decrease prices by a configurable percentage
-- **Batch processing**: Handles large inventories with automatic pagination
-- **Error resilience**: Continues processing even if individual products fail
-- **Rate limit handling**: Automatic retry logic for API rate limits
-- **Progress tracking**: Detailed logging to both console and Progress.md file
-- **Environment-based configuration**: Secure credential management via .env file
-
-## Prerequisites
-
-- Node.js (version 14 or higher)
-- A Shopify store with Admin API access
-- Shopify Private App or Custom App with the following permissions:
- - `read_products`
- - `write_products`
-
-## Installation
-
-1. Clone or download this repository
-2. Install dependencies:
- ```bash
- npm install
- ```
-3. Copy the environment template:
- ```bash
- copy .env.example .env
- ```
-4. Configure your environment variables (see Configuration section)
-
-## Configuration
-
-Edit the `.env` file with your Shopify store details:
-
-```env
-# Your Shopify store domain (without https://)
-SHOPIFY_SHOP_DOMAIN=your-store.myshopify.com
-
-# Your Shopify Admin API access token
-SHOPIFY_ACCESS_TOKEN=shpat_your_access_token_here
-
-# The product tag to filter by
-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):
-
-1. Go to your Shopify Admin → Apps → App and sales channel settings
-2. Click "Develop apps" → "Create an app"
-3. Configure Admin API access with `read_products` and `write_products` permissions
-4. Install the app and copy the Admin API access token
-
-#### For Custom Apps:
-
-1. Go to your Shopify Admin → Settings → Apps and sales channels
-2. Click "Develop apps" → "Create an app"
-3. Configure the required API permissions
-4. Generate and copy the access token
-
-## Usage
-
-### Basic Usage
-
-Run the script with your configured environment:
-
-```bash
-npm start
-```
-
-or
-
-```bash
-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:
-
-```bash
-npm run debug-tags
-```
-
-This will:
-
-- Show all products and their tags in your store
-- Check if your target tag exists
-- Suggest similar tags if exact match isn't found
-- Help troubleshoot tag-related issues
-
-### Example Scenarios
-
-#### Increase prices by 10% for sale items:
-
-```env
-TARGET_TAG=sale
-PRICE_ADJUSTMENT_PERCENTAGE=10
-```
-
-#### Decrease prices by 15% for clearance items:
-
-```env
-TARGET_TAG=clearance
-PRICE_ADJUSTMENT_PERCENTAGE=-15
-```
-
-#### Apply a 5.5% increase to seasonal products:
-
-```env
-TARGET_TAG=seasonal
-PRICE_ADJUSTMENT_PERCENTAGE=5.5
-```
-
-## Output and Logging
-
-The script provides detailed feedback in two ways:
-
-### Console Output
-
-- Configuration summary at startup
-- Real-time progress updates
-- Product-by-product price changes
-- Final summary with success/failure counts
-
-### Progress.md File
-
-- Persistent log of all operations
-- Timestamps for each run
-- Detailed error information for debugging
-- Historical record of price changes
-
-Example console output:
-
-```
-🚀 Starting Shopify Price Updater
-📋 Configuration:
- Store: your-store.myshopify.com
- Tag: sale
- Adjustment: +10%
-
-🔍 Found 25 products with tag 'sale'
-✅ Updated Product A: $19.99 → $21.99
-✅ Updated Product B: $29.99 → $32.99
-⚠️ Skipped Product C: Invalid price data
-...
-📊 Summary: 23 products updated, 2 skipped, 0 errors
-```
-
-## Error Handling
-
-The script is designed to be resilient:
-
-- **Rate Limits**: Automatically retries with exponential backoff
-- **Network Issues**: Retries failed requests up to 3 times
-- **Invalid Data**: Skips problematic products and continues
-- **API Errors**: Logs errors and continues with remaining products
-- **Missing Environment Variables**: Validates configuration before starting
-
-## Testing
-
-### Before Running on Production
-
-1. **Test with a development store** or backup your data
-2. **Start with a small subset** by using a specific tag with few products
-3. **Verify the percentage calculation** with known product prices
-4. **Check the Progress.md file** to ensure logging works correctly
-
-### Recommended Testing Process
-
-1. Create a test tag (e.g., "price-test") on a few products
-2. Set `TARGET_TAG=price-test` in your .env
-3. Run the script with a small percentage (e.g., 1%)
-4. Verify the changes in your Shopify admin
-5. Once satisfied, update your configuration for the actual run
-
-## Troubleshooting
-
-### Common Issues
-
-**"Authentication failed"**
-
-- Verify your `SHOPIFY_ACCESS_TOKEN` is correct
-- Ensure your app has `read_products` and `write_products` permissions
-
-**"No products found"**
-
-- Run `npm run debug-tags` to see all available tags in your store
-- Check that products actually have the specified tag
-- Tag matching is case-sensitive
-- Verify the tag format (some tags may have spaces, hyphens, or different capitalization)
-
-**"Rate limit exceeded"**
-
-- The script handles this automatically, but you can reduce load by processing smaller batches
-
-**"Invalid percentage"**
-
-- Ensure `PRICE_ADJUSTMENT_PERCENTAGE` is a valid number
-- Use negative values for price decreases
-
-### Debugging Steps
-
-1. **Run the debug script first**: `npm run debug-tags` to see what tags exist in your store
-2. **Check the Progress.md file** for detailed error information
-3. **Verify your .env configuration** matches the required format
-4. **Test with a small subset** of products first
-5. **Ensure your Shopify app** has the necessary permissions
-
-### Debug Scripts
-
-The project includes debugging tools:
-
-- `npm run debug-tags` - Analyze all product tags in your store
-- `debug-tags.js` - Standalone script to check tag availability and troubleshoot tag-related issues
-
-## Security Notes
-
-- Never commit your `.env` file to version control
-- Use environment-specific access tokens
-- Regularly rotate your API credentials
-- Test changes in a development environment first
-
-## File Structure
-
-```
-shopify-price-updater/
-├── src/
-│ ├── config/
-│ │ └── environment.js # Environment configuration
-│ ├── services/
-│ │ ├── shopify.js # Shopify API client
-│ │ ├── product.js # Product operations
-│ │ └── progress.js # Progress logging
-│ ├── utils/
-│ │ ├── price.js # Price calculations
-│ │ └── logger.js # Logging utilities
-│ └── index.js # Main entry point
-├── tests/ # Unit tests for the application
-├── debug-tags.js # Debug script to analyze store tags
-├── .env # Your configuration (create from .env.example)
-├── .env.example # Configuration template
-├── package.json # Dependencies and scripts
-├── Progress.md # Generated progress log
-└── README.md # This file
-```
-
-## Technical Details
-
-### API Implementation
-
-- Uses Shopify's GraphQL Admin API (version 2024-01)
-- Implements `productVariantsBulkUpdate` mutation for price updates
-- Built-in HTTPS client using Node.js native modules (no external HTTP dependencies)
-- Automatic tag formatting (handles both "tag" and "tag:tagname" formats)
-
-### Rate Limiting
-
-- Implements exponential backoff for rate limit handling
-- Maximum 3 retry attempts with increasing delays (1s, 2s, 4s)
-- Respects Shopify's API rate limits automatically
-
-### Error Recovery
-
-- Continues processing even if individual products fail
-- Comprehensive error categorization and reporting
-- Non-retryable errors are identified and logged appropriately
-
-## Available Scripts
-
-### Immediate Execution Scripts
-
-- `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)
-
-### Scheduled Execution Scripts
-
-- `npm run schedule-update` - Run scheduled price update (requires SCHEDULED_EXECUTION_TIME environment variable)
-- `npm run schedule-rollback` - Run scheduled price rollback (requires SCHEDULED_EXECUTION_TIME environment variable)
-
-#### Scheduling Examples
-
-**Schedule a sale to start at 10:30 AM on December 25th:**
-
-```bash
-# Set environment variable and run
-set SCHEDULED_EXECUTION_TIME=2024-12-25T10:30:00 && npm run schedule-update
-```
-
-**Schedule a sale to end (rollback) at midnight on January 1st:**
-
-```bash
-# Set environment variable and run
-set SCHEDULED_EXECUTION_TIME=2025-01-01T00:00:00 && npm run schedule-rollback
-```
-
-**Schedule with specific timezone (EST):**
-
-```bash
-# Set environment variable with timezone and run
-set SCHEDULED_EXECUTION_TIME=2024-12-25T10:30:00-05:00 && npm run schedule-update
-```
-
-**Using .env file for scheduling:**
-
-```env
-# Add to your .env file
-SCHEDULED_EXECUTION_TIME=2024-12-25T10:30:00
-OPERATION_MODE=update
-TARGET_TAG=sale
-PRICE_ADJUSTMENT_PERCENTAGE=10
-```
-
-Then run: `npm run schedule-update`
-
-**Common scheduling scenarios:**
-
-- **Black Friday sale start**: Schedule price decreases for Friday morning
-- **Sale end**: Schedule rollback to original prices after promotion period
-- **Seasonal pricing**: Schedule price adjustments for seasonal campaigns
-- **Flash sales**: Schedule short-term promotional pricing
-- **Holiday promotions**: Schedule price changes for specific holidays
-
-**Note**: When using scheduled execution, the script will display a countdown and wait until the specified time before executing the price updates. You can cancel the scheduled operation by pressing Ctrl+C during the waiting period.
-
-## License
-
-This project is provided as-is for educational and commercial use. Please test thoroughly before using in production environments.
-
-
-