diff --git a/.aicodeprep-gui b/.aicodeprep-gui
new file mode 100644
index 0000000..9f84663
--- /dev/null
+++ b/.aicodeprep-gui
@@ -0,0 +1,55 @@
+# .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
new file mode 100644
index 0000000..fa77ba5
--- /dev/null
+++ b/fullcode.txt
@@ -0,0 +1,16032 @@
+.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.
+
+
+
diff --git a/src/tui/TuiApplication.jsx b/src/tui/TuiApplication.jsx
index a68d7cb..9fc8598 100644
--- a/src/tui/TuiApplication.jsx
+++ b/src/tui/TuiApplication.jsx
@@ -1,8 +1,8 @@
const React = require("react");
const { Box, Text } = require("ink");
-const AppProvider = require("./providers/AppProvider");
-const Router = require("./components/Router");
-const StatusBar = require("./components/StatusBar");
+const AppProvider = require("./providers/AppProvider.jsx");
+const Router = require("./components/Router.jsx");
+const StatusBar = require("./components/StatusBar.jsx");
/**
* Main TUI Application Component
@@ -15,7 +15,7 @@ const TuiApplication = () => {
null,
React.createElement(
Box,
- { flexDirection: "column", height: "100%" },
+ { flexDirection: "column" },
React.createElement(StatusBar),
React.createElement(Router)
)
diff --git a/src/tui/components/Router.jsx b/src/tui/components/Router.jsx
index 93fcc8e..ab6faae 100644
--- a/src/tui/components/Router.jsx
+++ b/src/tui/components/Router.jsx
@@ -1,14 +1,14 @@
const React = require("react");
const { Box, Text } = require("ink");
-const { useAppState } = require("../providers/AppProvider");
+const { useAppState } = require("../providers/AppProvider.jsx");
-// 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');
+// Import screen components
+const MainMenuScreen = require("./screens/MainMenuScreen.jsx");
+const ConfigurationScreen = require("./screens/ConfigurationScreen.jsx");
+const OperationScreen = require("./screens/OperationScreen.jsx");
+const SchedulingScreen = require("./screens/SchedulingScreen.jsx");
+const LogViewerScreen = require("./screens/LogViewerScreen.jsx");
+const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
/**
* Router Component
@@ -18,44 +18,14 @@ const { useAppState } = require("../providers/AppProvider");
const Router = () => {
const { appState } = useAppState();
- // Temporary placeholder screens until actual screens are implemented
+ // Screen components mapping
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")
- ),
+ "main-menu": MainMenuScreen,
+ configuration: ConfigurationScreen,
+ operation: OperationScreen,
+ scheduling: SchedulingScreen,
+ logs: LogViewerScreen,
+ "tag-analysis": TagAnalysisScreen,
};
// Get the current screen component
diff --git a/src/tui/components/StatusBar.jsx b/src/tui/components/StatusBar.jsx
index cb37771..842fa98 100644
--- a/src/tui/components/StatusBar.jsx
+++ b/src/tui/components/StatusBar.jsx
@@ -1,6 +1,6 @@
const React = require("react");
const { Box, Text } = require("ink");
-const { useAppState } = require("../providers/AppProvider");
+const { useAppState } = require("../providers/AppProvider.jsx");
/**
* StatusBar Component
diff --git a/src/tui/components/screens/ConfigurationScreen.jsx b/src/tui/components/screens/ConfigurationScreen.jsx
new file mode 100644
index 0000000..c620c84
--- /dev/null
+++ b/src/tui/components/screens/ConfigurationScreen.jsx
@@ -0,0 +1,530 @@
+const React = require("react");
+const { Box, Text, useInput, useApp } = require("ink");
+const { useAppState } = require("../../providers/AppProvider.jsx");
+const TextInput = require("ink-text-input").default;
+
+/**
+ * Configuration Screen Component
+ * Form-based interface for setting up Shopify credentials and operation parameters
+ * Requirements: 5.2, 6.1, 6.2, 6.3
+ */
+const ConfigurationScreen = () => {
+ const { appState, updateConfiguration, navigateBack, updateUIState } =
+ useAppState();
+ const { exit } = useApp();
+
+ // Form fields configuration
+ const formFields = [
+ {
+ id: "shopDomain",
+ label: "Shopify Shop Domain",
+ placeholder: "your-store.myshopify.com",
+ description: "Your Shopify store domain (without https://)",
+ validator: (value) => {
+ if (!value || value.trim() === "") return "Domain is required";
+ if (!value.includes(".myshopify.com") && !value.includes(".")) {
+ return "Must be a valid Shopify domain";
+ }
+ return null;
+ },
+ },
+ {
+ id: "accessToken",
+ label: "Shopify Access Token",
+ placeholder: "shpat_your_access_token_here",
+ description: "Your Shopify Admin API access token",
+ secret: true,
+ validator: (value) => {
+ if (!value || value.trim() === "") return "Access token is required";
+ if (value.length < 10) return "Token appears to be too short";
+ return null;
+ },
+ },
+ {
+ id: "targetTag",
+ label: "Target Product Tag",
+ placeholder: "sale",
+ description: "Products with this tag will be updated",
+ validator: (value) => {
+ if (!value || value.trim() === "") return "Target tag is required";
+ return null;
+ },
+ },
+ {
+ id: "priceAdjustment",
+ label: "Price Adjustment Percentage",
+ placeholder: "10",
+ description:
+ "Percentage to adjust prices (positive for increase, negative for decrease)",
+ validator: (value) => {
+ if (!value || value.trim() === "") return "Percentage is required";
+ const num = parseFloat(value);
+ if (isNaN(num)) return "Must be a valid number";
+ return null;
+ },
+ },
+ {
+ id: "operationMode",
+ label: "Operation Mode",
+ placeholder: "update",
+ description:
+ "Choose between updating prices or rolling back to compare-at prices",
+ type: "select",
+ options: [
+ { value: "update", label: "Update Prices" },
+ { value: "rollback", label: "Rollback Prices" },
+ ],
+ },
+ ];
+
+ // State for form inputs
+ const [formValues, setFormValues] = React.useState(
+ formFields.reduce((acc, field) => {
+ acc[field.id] = appState.configuration[field.id] || "";
+ return acc;
+ }, {})
+ );
+
+ const [errors, setErrors] = React.useState({});
+ const [focusedField, setFocusedField] = React.useState(0);
+ const [showValidation, setShowValidation] = React.useState(false);
+
+ // Validate all fields
+ const validateForm = () => {
+ const newErrors = {};
+ let isValid = true;
+
+ formFields.forEach((field) => {
+ const error = field.validator(formValues[field.id]);
+ if (error) {
+ newErrors[field.id] = error;
+ isValid = false;
+ }
+ });
+
+ setErrors(newErrors);
+ return isValid;
+ };
+
+ // Handle keyboard input
+ useInput((input, key) => {
+ if (key.escape) {
+ // Go back to main menu
+ navigateBack();
+ } else if (key.tab || (key.tab && key.shift)) {
+ // Navigate between fields
+ if (key.shift) {
+ // Shift+Tab - previous field
+ setFocusedField((prev) =>
+ prev > 0 ? prev - 1 : formFields.length - 1
+ );
+ } else {
+ // Tab - next field
+ setFocusedField((prev) =>
+ prev < formFields.length - 1 ? prev + 1 : 0
+ );
+ }
+ } else if (key.return || key.enter) {
+ // Handle Enter key
+ if (focusedField === formFields.length - 1) {
+ // Last field (Save button) - save configuration
+ handleSave();
+ } else {
+ // Move to next field
+ setFocusedField((prev) =>
+ prev < formFields.length - 1 ? prev + 1 : 0
+ );
+ }
+ } else if (key.upArrow) {
+ // Navigate up
+ setFocusedField((prev) => (prev > 0 ? prev - 1 : formFields.length - 1));
+ } else if (key.downArrow) {
+ // Navigate down
+ setFocusedField((prev) => (prev < formFields.length - 1 ? prev + 1 : 0));
+ }
+ });
+
+ // Handle input changes
+ const handleInputChange = (fieldId, value) => {
+ setFormValues((prev) => ({
+ ...prev,
+ [fieldId]: value,
+ }));
+
+ // Clear error when user starts typing
+ if (errors[fieldId]) {
+ setErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[fieldId];
+ return newErrors;
+ });
+ }
+ };
+
+ // Handle save configuration
+ const handleSave = () => {
+ if (validateForm()) {
+ // Convert price adjustment to number
+ const config = {
+ ...formValues,
+ priceAdjustment: parseFloat(formValues.priceAdjustment),
+ isValid: true,
+ lastTested: null,
+ };
+
+ updateConfiguration(config);
+
+ // Save to environment variables
+ saveToEnvironment(config);
+
+ navigateBack();
+ } else {
+ // Show validation errors
+ setShowValidation(true);
+ }
+ };
+
+ // Save configuration to environment variables
+ const saveToEnvironment = (config) => {
+ try {
+ const fs = require("fs");
+ const path = require("path");
+ const envPath = path.resolve(__dirname, "../../../.env");
+
+ let envContent = "";
+ try {
+ envContent = fs.readFileSync(envPath, "utf8");
+ } catch (err) {
+ // If file doesn't exist, create it
+ envContent = "";
+ }
+
+ // Update or add each configuration value
+ const envVars = {
+ SHOPIFY_SHOP_DOMAIN: config.shopDomain,
+ SHOPIFY_ACCESS_TOKEN: config.accessToken,
+ TARGET_TAG: config.targetTag,
+ PRICE_ADJUSTMENT_PERCENTAGE: config.priceAdjustment,
+ OPERATION_MODE: config.operationMode,
+ };
+
+ for (const [key, value] of Object.entries(envVars)) {
+ const regex = new RegExp(`^${key}=.*`, "m");
+ const line = `${key}=${value}`;
+
+ if (envContent.match(regex)) {
+ envContent = envContent.replace(regex, line);
+ } else {
+ envContent += `\n${line}`;
+ }
+ }
+
+ fs.writeFileSync(envPath, envContent);
+ } catch (error) {
+ console.error("Failed to save configuration to environment:", error);
+ }
+ };
+
+ // Handle test connection
+ const handleTestConnection = async () => {
+ // Validate required fields first
+ const requiredFields = ["shopDomain", "accessToken"];
+ const tempErrors = {};
+ let hasError = false;
+
+ requiredFields.forEach((fieldId) => {
+ const field = formFields.find((f) => f.id === fieldId);
+ const error = field.validator(formValues[fieldId]);
+ if (error) {
+ tempErrors[fieldId] = error;
+ hasError = true;
+ }
+ });
+
+ if (hasError) {
+ setErrors(tempErrors);
+ setShowValidation(true);
+ return;
+ }
+
+ // Update configuration temporarily for testing
+ updateConfiguration({
+ ...formValues,
+ priceAdjustment: parseFloat(formValues.priceAdjustment),
+ isValid: false,
+ });
+
+ // Test connection (this would integrate with the actual service)
+ // For now, we'll simulate the test
+ setFocusedField(formFields.length); // Show loading state
+ await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate API call
+
+ // Simulate test result
+ const testSuccess = Math.random() > 0.2; // 80% success rate for demo
+ updateConfiguration({
+ ...formValues,
+ priceAdjustment: parseFloat(formValues.priceAdjustment),
+ isValid: testSuccess,
+ lastTested: new Date(),
+ });
+
+ setFocusedField(formFields.length - 1); // Return to save button
+ };
+
+ return React.createElement(
+ Box,
+ { flexDirection: "column", padding: 1 },
+ // Header
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 1 },
+ React.createElement(
+ Text,
+ { bold: true, color: "cyan" },
+ "⚙️ Configuration"
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ "Set up your Shopify credentials and operation parameters"
+ )
+ ),
+
+ // Form fields
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 2 },
+ formFields.map((field, index) => {
+ const isFocused = focusedField === index;
+ const hasError = errors[field.id];
+ const currentValue = formValues[field.id];
+
+ return React.createElement(
+ Box,
+ {
+ key: field.id,
+ borderStyle: "single",
+ borderColor: hasError ? "red" : isFocused ? "blue" : "gray",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 1,
+ flexDirection: "column",
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center", marginBottom: 1 },
+ React.createElement(
+ Text,
+ {
+ bold: true,
+ color: isFocused ? "blue" : "white",
+ },
+ `${field.label}:`
+ ),
+ React.createElement(
+ Box,
+ { flexGrow: 1 },
+ React.createElement(
+ Text,
+ { color: "gray" },
+ ` ${field.description}`
+ )
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row" },
+ field.type === "select"
+ ? React.createElement(
+ Box,
+ { flexDirection: "column" },
+ field.options.map((option, optIndex) =>
+ React.createElement(
+ Box,
+ {
+ key: optIndex,
+ borderStyle:
+ formValues[field.id] === option.value
+ ? "single"
+ : "none",
+ borderColor: "blue",
+ paddingX: 2,
+ paddingY: 0.5,
+ marginBottom: 0.5,
+ backgroundColor:
+ formValues[field.id] === option.value
+ ? "blue"
+ : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color:
+ formValues[field.id] === option.value
+ ? "white"
+ : "gray",
+ },
+ option.label
+ )
+ )
+ )
+ )
+ : React.createElement(TextInput, {
+ value: currentValue,
+ placeholder: field.placeholder,
+ mask: field.secret ? "*" : null,
+ showCursor: isFocused,
+ focus: isFocused,
+ onChange: (value) => handleInputChange(field.id, value),
+ style: {
+ color: hasError ? "red" : isFocused ? "blue" : "white",
+ bold: isFocused,
+ },
+ })
+ ),
+ hasError &&
+ React.createElement(
+ Text,
+ { color: "red", italic: true },
+ ` Error: ${errors[field.id]}`
+ )
+ );
+ })
+ ),
+
+ // Action buttons
+ React.createElement(
+ Box,
+ { flexDirection: "row", justifyContent: "space-between" },
+ React.createElement(
+ Box,
+ { flexDirection: "column", width: "48%" },
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "gray",
+ paddingX: 2,
+ paddingY: 1,
+ alignItems: "center",
+ backgroundColor:
+ focusedField === formFields.length ? "yellow" : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color: focusedField === formFields.length ? "black" : "white",
+ bold: true,
+ },
+ focusedField === formFields.length
+ ? "Testing..."
+ : "Test Connection"
+ )
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true, marginTop: 0.5 },
+ "Verify your credentials"
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "column", width: "48%" },
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "green",
+ paddingX: 2,
+ paddingY: 1,
+ alignItems: "center",
+ backgroundColor:
+ focusedField === formFields.length - 1 ? "green" : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color: "white",
+ bold: true,
+ },
+ "Save & Exit"
+ )
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true, marginTop: 0.5 },
+ "Save configuration and return to menu"
+ )
+ )
+ ),
+
+ // Configuration status
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 2,
+ borderTopStyle: "single",
+ borderColor: "gray",
+ paddingTop: 2,
+ },
+ React.createElement(
+ Text,
+ {
+ color: appState.configuration.isValid ? "green" : "red",
+ bold: true,
+ },
+ `Configuration Status: ${
+ appState.configuration.isValid ? "✓ Valid" : "⚠ Incomplete"
+ }`
+ ),
+ appState.configuration.lastTested &&
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ `Last tested: ${appState.configuration.lastTested.toLocaleString()}`
+ )
+ ),
+
+ // Instructions
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginTop: 2 },
+ React.createElement(Text, { color: "gray" }, "Navigation:"),
+ React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate fields"),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ " Tab/Shift+Tab - Next/Previous field"
+ ),
+ React.createElement(Text, { color: "gray" }, " Enter - Save/Next field"),
+ React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
+ ),
+
+ // Validation message
+ showValidation &&
+ Object.keys(errors).length > 0 &&
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 2,
+ padding: 1,
+ borderStyle: "single",
+ borderColor: "red",
+ },
+ React.createElement(
+ Text,
+ { color: "red", bold: true },
+ "Validation Errors:"
+ ),
+ React.createElement(
+ Text,
+ { color: "red" },
+ "Please fix the errors before saving."
+ )
+ )
+ );
+};
+
+module.exports = ConfigurationScreen;
diff --git a/src/tui/components/screens/LogViewerScreen.jsx b/src/tui/components/screens/LogViewerScreen.jsx
new file mode 100644
index 0000000..c4e8c1e
--- /dev/null
+++ b/src/tui/components/screens/LogViewerScreen.jsx
@@ -0,0 +1,411 @@
+const React = require("react");
+const { Box, Text, useInput, useApp } = require("ink");
+const { useAppState } = require("../../providers/AppProvider.jsx");
+const SelectInput = require("ink-select-input").default;
+
+/**
+ * Log Viewer Screen Component
+ * Displays application logs with filtering and navigation capabilities
+ * Requirements: 7.5, 7.6
+ */
+const LogViewerScreen = () => {
+ const { appState, navigateBack } = useAppState();
+ const { exit } = useApp();
+
+ // Mock log data (in a real implementation, this would read from log files)
+ const mockLogs = [
+ {
+ timestamp: new Date(Date.now() - 1000 * 60 * 5),
+ level: "INFO",
+ message: "Application started",
+ details: "Shopify Price Updater v1.0.0",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 60 * 4),
+ level: "INFO",
+ message: "Configuration loaded",
+ details: "Target tag: 'sale', Adjustment: 10%",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 60 * 3),
+ level: "INFO",
+ message: "Testing Shopify connection",
+ details: "Connecting to store...",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 60 * 2),
+ level: "SUCCESS",
+ message: "Connection successful",
+ details: "API version: 2023-01",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 60 * 1),
+ level: "INFO",
+ message: "Fetching products",
+ details: "Query: tag:sale",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 60 * 1),
+ level: "INFO",
+ message: "Products found",
+ details: "Found 15 products with tag 'sale'",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 30),
+ level: "INFO",
+ message: "Starting price updates",
+ details: "Processing 15 products, 42 variants total",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 25),
+ level: "INFO",
+ message: "Updating product",
+ details: "Product: 'Summer T-Shirt' - New price: $22.00",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 20),
+ level: "INFO",
+ message: "Updating product",
+ details: "Product: 'Winter Jacket' - New price: $110.00",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 15),
+ level: "WARNING",
+ message: "Price update failed",
+ details: "Product: 'Limited Edition Sneaker' - Insufficient permissions",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 10),
+ level: "INFO",
+ message: "Updating product",
+ details: "Product: 'Casual Jeans' - New price: $45.00",
+ },
+ {
+ timestamp: new Date(Date.now() - 1000 * 5),
+ level: "SUCCESS",
+ message: "Operation completed",
+ details: "Updated 40 of 42 variants (95.2% success rate)",
+ },
+ ];
+
+ // State for log viewing
+ const [logs, setLogs] = React.useState(mockLogs);
+ const [filteredLogs, setFilteredLogs] = React.useState(mockLogs);
+ const [filterLevel, setFilterLevel] = React.useState("ALL");
+ const [selectedLog, setSelectedLog] = React.useState(null);
+ const [showDetails, setShowDetails] = React.useState(false);
+ const [scrollPosition, setScrollPosition] = React.useState(0);
+ const [maxScroll, setMaxScroll] = React.useState(0);
+
+ // Filter options
+ const filterOptions = [
+ { value: "ALL", label: "All Levels" },
+ { value: "ERROR", label: "Errors" },
+ { value: "WARNING", label: "Warnings" },
+ { value: "INFO", label: "Info" },
+ { value: "SUCCESS", label: "Success" },
+ ];
+
+ // Filter logs based on selected level
+ const filterLogs = () => {
+ if (filterLevel === "ALL") {
+ setFilteredLogs(logs);
+ } else {
+ setFilteredLogs(logs.filter((log) => log.level === filterLevel));
+ }
+ };
+
+ // Handle keyboard input
+ useInput((input, key) => {
+ if (key.escape) {
+ // Go back to main menu
+ navigateBack();
+ } else if (key.upArrow) {
+ // Navigate up in log list
+ if (selectedLog > 0) {
+ setSelectedLog(selectedLog - 1);
+ setShowDetails(false);
+ }
+ } else if (key.downArrow) {
+ // Navigate down in log list
+ if (selectedLog < filteredLogs.length - 1) {
+ setSelectedLog(selectedLog + 1);
+ setShowDetails(false);
+ }
+ } else if (key.return || key.enter) {
+ // Toggle log details
+ if (selectedLog !== null) {
+ setShowDetails(!showDetails);
+ }
+ } else if (key.r) {
+ // Refresh logs
+ setLogs(mockLogs);
+ setFilteredLogs(mockLogs);
+ setSelectedLog(null);
+ setShowDetails(false);
+ }
+ });
+
+ // Handle filter change
+ const handleFilterChange = (option) => {
+ setFilterLevel(option.value);
+ };
+
+ // Get log level color
+ const getLogLevelColor = (level) => {
+ switch (level) {
+ case "ERROR":
+ return "red";
+ case "WARNING":
+ return "yellow";
+ case "INFO":
+ return "blue";
+ case "SUCCESS":
+ return "green";
+ default:
+ return "white";
+ }
+ };
+
+ // Format timestamp
+ const formatTimestamp = (date) => {
+ return date.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ };
+
+ return React.createElement(
+ Box,
+ { flexDirection: "column", padding: 2, flexGrow: 1 },
+ // Header
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 2 },
+ React.createElement(Text, { bold: true, color: "cyan" }, "📋 Log Viewer"),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ "View application logs and operation history"
+ )
+ ),
+
+ // Filter controls
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "blue",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "blue" },
+ "Log Filters:"
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(Text, { color: "white" }, "Level: "),
+ React.createElement(SelectInput, {
+ items: filterOptions,
+ selectedIndex: filterOptions.findIndex(
+ (opt) => opt.value === filterLevel
+ ),
+ onSelect: handleFilterChange,
+ itemComponent: ({ label, isSelected }) =>
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "blue" : "white",
+ bold: isSelected,
+ },
+ label
+ ),
+ })
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ `Showing ${filteredLogs.length} of ${logs.length} log entries`
+ )
+ )
+ ),
+
+ // Log list
+ React.createElement(
+ Box,
+ {
+ flexGrow: 1,
+ borderStyle: "single",
+ borderColor: "gray",
+ flexDirection: "column",
+ },
+ filteredLogs.map((log, index) => {
+ const isSelected = selectedLog === index;
+ const isHighlighted = isSelected && !showDetails;
+
+ return React.createElement(
+ Box,
+ {
+ key: index,
+ borderStyle: "single",
+ borderColor: isSelected ? "blue" : "transparent",
+ paddingX: 1,
+ paddingY: 0.5,
+ backgroundColor: isHighlighted ? "blue" : undefined,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ {
+ color: getLogLevelColor(log.level),
+ bold: true,
+ width: 8,
+ },
+ log.level
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isHighlighted ? "white" : "gray",
+ width: 8,
+ },
+ formatTimestamp(log.timestamp)
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isHighlighted ? "white" : "white",
+ flexGrow: 1,
+ },
+ log.message
+ )
+ )
+ );
+ })
+ ),
+
+ // Log details (when selected)
+ showDetails &&
+ selectedLog !== null &&
+ filteredLogs[selectedLog] &&
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "green",
+ paddingX: 1,
+ paddingY: 1,
+ marginTop: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "green" },
+ "Log Details:"
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ { color: "white", bold: true },
+ "Level: "
+ ),
+ React.createElement(
+ Text,
+ { color: getLogLevelColor(filteredLogs[selectedLog].level) },
+ filteredLogs[selectedLog].level
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(Text, { color: "white", bold: true }, "Time: "),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ filteredLogs[selectedLog].timestamp.toLocaleString()
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginTop: 1 },
+ React.createElement(
+ Text,
+ { color: "white", bold: true },
+ "Message:"
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ filteredLogs[selectedLog].message
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginTop: 1 },
+ React.createElement(
+ Text,
+ { color: "white", bold: true },
+ "Details:"
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true },
+ filteredLogs[selectedLog].details
+ )
+ )
+ )
+ ),
+
+ // Instructions
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 2,
+ borderTopStyle: "single",
+ borderColor: "gray",
+ paddingTop: 2,
+ },
+ React.createElement(Text, { color: "gray" }, "Controls:"),
+ React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate logs"),
+ React.createElement(Text, { color: "gray" }, " Enter - View details"),
+ React.createElement(Text, { color: "gray" }, " R - Refresh logs"),
+ React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
+ ),
+
+ // Status bar
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "gray",
+ paddingX: 1,
+ paddingY: 0.5,
+ marginTop: 1,
+ },
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ `Selected: ${
+ selectedLog !== null ? `Log #${selectedLog + 1}` : "None"
+ } | Details: ${showDetails ? "Visible" : "Hidden"}`
+ )
+ )
+ );
+};
+
+module.exports = LogViewerScreen;
diff --git a/src/tui/components/screens/MainMenuScreen.jsx b/src/tui/components/screens/MainMenuScreen.jsx
new file mode 100644
index 0000000..0374e65
--- /dev/null
+++ b/src/tui/components/screens/MainMenuScreen.jsx
@@ -0,0 +1,203 @@
+const React = require("react");
+const { Box, Text, useInput } = require("ink");
+const { useAppState } = require("../../providers/AppProvider.jsx");
+
+/**
+ * Main Menu Screen Component
+ * Provides the primary navigation interface for the application
+ * Requirements: 5.1, 5.3, 7.1
+ */
+const MainMenuScreen = () => {
+ const { appState, navigateTo, updateUIState } = useAppState();
+
+ // Menu items configuration
+ const menuItems = [
+ {
+ id: "configuration",
+ label: "Configuration",
+ description: "Set up Shopify credentials and operation parameters",
+ },
+ {
+ id: "operation",
+ label: "Run Operation",
+ description: "Execute price update or rollback operation",
+ },
+ {
+ id: "scheduling",
+ label: "Scheduling",
+ description: "Configure scheduled operations",
+ },
+ {
+ id: "tag-analysis",
+ label: "Tag Analysis",
+ description: "Explore product tags in your store",
+ },
+ {
+ id: "logs",
+ label: "View Logs",
+ description: "Browse operation logs and history",
+ },
+ { id: "exit", label: "Exit", description: "Quit the application" },
+ ];
+
+ // Handle keyboard input
+ useInput((input, key) => {
+ if (key.upArrow) {
+ // Navigate up in menu
+ const newIndex = Math.max(0, appState.uiState.selectedMenuIndex - 1);
+ updateUIState({ selectedMenuIndex: newIndex });
+ } else if (key.downArrow) {
+ // Navigate down in menu
+ const newIndex = Math.min(
+ menuItems.length - 1,
+ appState.uiState.selectedMenuIndex + 1
+ );
+ updateUIState({ selectedMenuIndex: newIndex });
+ } else if (key.return || key.enter || input === " ") {
+ // Select menu item
+ const selectedItem = menuItems[appState.uiState.selectedMenuIndex];
+ if (selectedItem.id === "exit") {
+ // Exit the application
+ process.exit(0);
+ } else {
+ // Navigate to selected screen
+ navigateTo(selectedItem.id);
+ }
+ } else if (input === "q" || input === "Q") {
+ // Quick exit with 'q'
+ process.exit(0);
+ }
+ });
+
+ return React.createElement(
+ Box,
+ { flexDirection: "column", padding: 1 },
+ // Header
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 1 },
+ React.createElement(
+ Text,
+ { bold: true, color: "cyan" },
+ "🛍️ Shopify Price Updater"
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ "Terminal User Interface"
+ )
+ ),
+
+ // Welcome message
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 1 },
+ React.createElement(
+ Text,
+ { color: "green", fontSize: "small" },
+ "Shopify Price Updater TUI"
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ "Arrow keys: Navigate | Enter: Select"
+ )
+ ),
+
+ // Menu items
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ menuItems.map((item, index) => {
+ const isSelected = index === appState.uiState.selectedMenuIndex;
+ const isConfigured = appState.configuration.isValid;
+
+ return React.createElement(
+ Box,
+ {
+ key: item.id,
+ borderStyle: "single",
+ borderColor: isSelected ? "blue" : "gray",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 1,
+ flexDirection: "column",
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ {
+ bold: isSelected,
+ color: isSelected ? "blue" : "white",
+ },
+ `${isSelected ? "▶" : " "} ${item.label}`
+ ),
+ // Configuration status indicator
+ item.id === "operation" &&
+ !isConfigured &&
+ React.createElement(
+ Box,
+ { marginLeft: 2 },
+ React.createElement(Text, { color: "red" }, "⚠️ Not Configured")
+ )
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "cyan" : "gray",
+ italic: true,
+ },
+ ` ${item.description}`
+ )
+ );
+ })
+ ),
+
+ // Footer with instructions
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 3,
+ borderTopStyle: "single",
+ borderColor: "gray",
+ paddingTop: 2,
+ },
+ React.createElement(Text, { color: "gray" }, "Navigation:"),
+ React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate menu"),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ " Enter/Space - Select item"
+ ),
+ React.createElement(Text, { color: "gray" }, " q - Quick exit"),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ " Esc - Back (when available)"
+ )
+ ),
+
+ // Configuration status
+ React.createElement(
+ Box,
+ { flexDirection: "row", justifyContent: "space-between", marginTop: 2 },
+ React.createElement(
+ Text,
+ { color: appState.configuration.isValid ? "green" : "red" },
+ `Configuration: ${
+ appState.configuration.isValid ? "✓ Complete" : "⚠ Incomplete"
+ }`
+ ),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ `Mode: ${appState.configuration.operationMode.toUpperCase()}`
+ )
+ )
+ );
+};
+
+module.exports = MainMenuScreen;
diff --git a/src/tui/components/screens/OperationScreen.jsx b/src/tui/components/screens/OperationScreen.jsx
new file mode 100644
index 0000000..130e113
--- /dev/null
+++ b/src/tui/components/screens/OperationScreen.jsx
@@ -0,0 +1,484 @@
+const React = require("react");
+const { Box, Text, useInput, useApp } = require("ink");
+const { useAppState } = require("../../providers/AppProvider.jsx");
+const Spinner = require("ink-spinner").default;
+const ProgressBar = require("../common/ProgressBar.jsx");
+
+/**
+ * Operation Screen Component
+ * Handles execution of price update or rollback operations with real-time progress monitoring
+ * Requirements: 7.2, 8.1, 8.2, 8.3, 8.4
+ */
+const OperationScreen = () => {
+ const { appState, navigateBack, updateOperationState } = useAppState();
+ const { exit } = useApp();
+
+ // Operation states
+ const [operationStatus, setOperationStatus] = React.useState("ready"); // ready, running, completed, cancelled, error
+ const [progress, setProgress] = React.useState(0);
+ const [currentStep, setCurrentStep] = React.useState("");
+ const [productsProcessed, setProductsProcessed] = React.useState(0);
+ const [totalProducts, setTotalProducts] = React.useState(0);
+ const [errors, setErrors] = React.useState([]);
+ const [results, setResults] = React.useState(null);
+
+ // Simulate operation execution (would integrate with actual services)
+ const executeOperation = async () => {
+ if (!appState.configuration.isValid) {
+ setOperationStatus("error");
+ setErrors([
+ "Configuration is not valid. Please configure your settings first.",
+ ]);
+ return;
+ }
+
+ setOperationStatus("running");
+ setProgress(0);
+ setErrors([]);
+ setResults(null);
+
+ try {
+ // Simulate fetching products
+ setCurrentStep("Fetching products...");
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ // Simulate finding products
+ const mockProducts = [
+ { id: "1", title: "Product A", variants: [{ id: "v1", price: 19.99 }] },
+ { id: "2", title: "Product B", variants: [{ id: "v2", price: 29.99 }] },
+ { id: "3", title: "Product C", variants: [{ id: "v3", price: 39.99 }] },
+ { id: "4", title: "Product D", variants: [{ id: "v4", price: 49.99 }] },
+ { id: "5", title: "Product E", variants: [{ id: "v5", price: 59.99 }] },
+ ];
+
+ setTotalProducts(mockProducts.length);
+ setProductsProcessed(0);
+
+ // Simulate processing each product
+ for (let i = 0; i < mockProducts.length; i++) {
+ const product = mockProducts[i];
+
+ // Simulate API call delay
+ await new Promise((resolve) => setTimeout(resolve, 800));
+
+ // Update progress
+ const newProgress = ((i + 1) / mockProducts.length) * 100;
+ setProgress(newProgress);
+ setProductsProcessed(i + 1);
+ setCurrentStep(`Processing: ${product.title}`);
+
+ // Simulate occasional errors
+ if (Math.random() < 0.1 && i > 0) {
+ // 10% chance of error after first product
+ const error = {
+ productTitle: product.title,
+ productId: product.id,
+ errorMessage: "Simulated API error",
+ };
+ setErrors((prev) => [...prev, error]);
+ }
+ }
+
+ // Complete operation
+ setOperationStatus("completed");
+ setCurrentStep("Operation completed!");
+ setResults({
+ totalProducts: mockProducts.length,
+ totalVariants: mockProducts.reduce(
+ (sum, p) => sum + p.variants.length,
+ 0
+ ),
+ successfulUpdates: mockProducts.length - errors.length,
+ failedUpdates: errors.length,
+ errors: errors,
+ });
+ } catch (error) {
+ setOperationStatus("error");
+ setErrors([
+ { productTitle: "System Error", errorMessage: error.message },
+ ]);
+ }
+ };
+
+ // Handle keyboard input
+ useInput((input, key) => {
+ if (key.escape) {
+ // Cancel operation if running
+ if (operationStatus === "running") {
+ setOperationStatus("cancelled");
+ setCurrentStep("Operation cancelled by user");
+ } else {
+ // Go back to main menu
+ navigateBack();
+ }
+ } else if ((key.return || key.enter) && operationStatus === "ready") {
+ // Start operation
+ executeOperation();
+ }
+ });
+
+ // Update operation state in context
+ React.useEffect(() => {
+ updateOperationState({
+ status: operationStatus,
+ progress: progress,
+ currentStep: currentStep,
+ errors: errors,
+ results: results,
+ });
+ }, [operationStatus, progress, currentStep, errors, results]);
+
+ return React.createElement(
+ Box,
+ { flexDirection: "column", padding: 1 },
+ // Header
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 1 },
+ React.createElement(
+ Text,
+ {
+ bold: true,
+ color: operationStatus === "running" ? "yellow" : "cyan",
+ },
+ `🚀 ${
+ appState.configuration.operationMode === "rollback"
+ ? "Price Rollback"
+ : "Price Update"
+ } Operation`
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ `Target tag: ${appState.configuration.targetTag || "Not set"}`
+ )
+ ),
+
+ // Configuration summary
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "blue",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "blue" },
+ "Configuration Summary:"
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Store: ${appState.configuration.shopDomain || "Not set"}`
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Target Tag: ${appState.configuration.targetTag || "Not set"}`
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Mode: ${appState.configuration.operationMode.toUpperCase()}`
+ ),
+ appState.configuration.operationMode === "update" &&
+ React.createElement(
+ Text,
+ null,
+ ` Adjustment: ${appState.configuration.priceAdjustment || 0}%`
+ )
+ )
+ ),
+
+ // Operation status
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 3 },
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center", marginBottom: 1 },
+ React.createElement(
+ Text,
+ {
+ bold: true,
+ color: getStatusColor(operationStatus),
+ },
+ getStatusIcon(operationStatus) + " " + getStatusText(operationStatus)
+ ),
+ operationStatus === "running" &&
+ React.createElement(Spinner, { type: "dots" })
+ ),
+ currentStep &&
+ React.createElement(
+ Text,
+ { color: "gray", italic: true },
+ `Current step: ${currentStep}`
+ )
+ ),
+
+ // Progress bar
+ operationStatus !== "ready" &&
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 3 },
+ React.createElement(ProgressBar, {
+ value: progress,
+ max: 100,
+ label: "Operation Progress",
+ showPercentage: true,
+ showValue: false,
+ style: {
+ bar: { fg: "green" },
+ empty: { fg: "gray" },
+ label: { fg: "white", bold: true },
+ percentage: { fg: "cyan" },
+ },
+ }),
+ React.createElement(
+ Box,
+ { flexDirection: "row", justifyContent: "space-between" },
+ React.createElement(
+ Text,
+ { color: "gray" },
+ `Products: ${productsProcessed}/${totalProducts}`
+ ),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ `${Math.round(progress)}%`
+ )
+ )
+ ),
+
+ // Results (when operation is completed)
+ operationStatus === "completed" &&
+ results &&
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "green",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "green" },
+ "Operation Results:"
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Total Products: ${results.totalProducts}`
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Total Variants: ${results.totalVariants}`
+ ),
+ React.createElement(
+ Text,
+ { color: "green" },
+ ` Successful: ${results.successfulUpdates}`
+ ),
+ React.createElement(
+ Text,
+ { color: results.failedUpdates > 0 ? "yellow" : "green" },
+ ` Failed: ${results.failedUpdates}`
+ )
+ )
+ ),
+
+ // Errors (if any)
+ errors.length > 0 &&
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "red",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "red" },
+ "Errors Encountered:"
+ ),
+ errors
+ .slice(0, 3)
+ .map((error, index) =>
+ React.createElement(
+ Box,
+ { key: index, flexDirection: "column" },
+ React.createElement(
+ Text,
+ { color: "red" },
+ ` • ${error.productTitle}: ${error.errorMessage}`
+ )
+ )
+ ),
+ errors.length > 3 &&
+ React.createElement(
+ Text,
+ { color: "red", italic: true },
+ ` ... and ${errors.length - 3} more errors`
+ )
+ )
+ ),
+
+ // Action buttons
+ React.createElement(
+ Box,
+ { flexDirection: "row", justifyContent: "space-between", marginTop: 2 },
+ React.createElement(
+ Box,
+ { flexDirection: "column", width: "48%" },
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: operationStatus === "ready" ? "green" : "gray",
+ paddingX: 2,
+ paddingY: 1,
+ alignItems: "center",
+ backgroundColor: operationStatus === "ready" ? "green" : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color: operationStatus === "ready" ? "white" : "gray",
+ bold: true,
+ },
+ operationStatus === "ready"
+ ? "Start Operation"
+ : "Operation Running"
+ )
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true, marginTop: 0.5 },
+ operationStatus === "ready"
+ ? "Begin price update"
+ : "Processing products..."
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "column", width: "48%" },
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "red",
+ paddingX: 2,
+ paddingY: 1,
+ alignItems: "center",
+ backgroundColor: operationStatus === "running" ? "red" : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color: "white",
+ bold: true,
+ },
+ "Cancel Operation"
+ )
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true, marginTop: 0.5 },
+ operationStatus === "running"
+ ? "Stop current operation"
+ : "Not running"
+ )
+ )
+ ),
+
+ // Instructions
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 2,
+ borderTopStyle: "single",
+ borderColor: "gray",
+ paddingTop: 2,
+ },
+ React.createElement(Text, { color: "gray" }, "Controls:"),
+ React.createElement(Text, { color: "gray" }, " Enter - Start operation"),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ " Esc - Cancel/Back to menu"
+ )
+ )
+ );
+};
+
+// Helper functions
+function getStatusColor(status) {
+ switch (status) {
+ case "ready":
+ return "blue";
+ case "running":
+ return "yellow";
+ case "completed":
+ return "green";
+ case "cancelled":
+ return "yellow";
+ case "error":
+ return "red";
+ default:
+ return "white";
+ }
+}
+
+function getStatusIcon(status) {
+ switch (status) {
+ case "ready":
+ return "⏳";
+ case "running":
+ return "🔄";
+ case "completed":
+ return "✅";
+ case "cancelled":
+ return "⏹️";
+ case "error":
+ return "❌";
+ default:
+ return "❓";
+ }
+}
+
+function getStatusText(status) {
+ switch (status) {
+ case "ready":
+ return "Ready to Start";
+ case "running":
+ return "Operation in Progress";
+ case "completed":
+ return "Operation Completed";
+ case "cancelled":
+ return "Operation Cancelled";
+ case "error":
+ return "Operation Error";
+ default:
+ return "Unknown Status";
+ }
+}
+
+module.exports = OperationScreen;
diff --git a/src/tui/components/screens/SchedulingScreen.jsx b/src/tui/components/screens/SchedulingScreen.jsx
new file mode 100644
index 0000000..f4b91ec
--- /dev/null
+++ b/src/tui/components/screens/SchedulingScreen.jsx
@@ -0,0 +1,505 @@
+const React = require("react");
+const { Box, Text, useInput, useApp } = require("ink");
+const { useAppState } = require("../../providers/AppProvider.jsx");
+const TextInput = require("ink-text-input").default;
+
+/**
+ * Scheduling Screen Component
+ * Interface for configuring scheduled price update operations
+ * Requirements: 7.3, 7.4
+ */
+const SchedulingScreen = () => {
+ const { appState, navigateBack, updateConfiguration } = useAppState();
+ const { exit } = useApp();
+
+ // Form fields configuration
+ const formFields = [
+ {
+ id: "scheduledTime",
+ label: "Scheduled Execution Time",
+ placeholder: "2023-12-25T15:30:00",
+ description: "When to run the operation (ISO 8601 format)",
+ validator: (value) => {
+ if (!value || value.trim() === "") return "Scheduled time is required";
+ try {
+ const date = new Date(value);
+ if (isNaN(date.getTime())) return "Invalid date format";
+ if (date <= new Date()) return "Scheduled time must be in the future";
+ return null;
+ } catch (error) {
+ return "Invalid date format";
+ }
+ },
+ },
+ {
+ id: "scheduleType",
+ label: "Schedule Type",
+ placeholder: "one-time",
+ description: "Type of schedule",
+ type: "select",
+ options: [
+ { value: "one-time", label: "One-time" },
+ { value: "daily", label: "Daily" },
+ { value: "weekly", label: "Weekly" },
+ { value: "monthly", label: "Monthly" },
+ ],
+ },
+ {
+ id: "scheduleTime",
+ label: "Schedule Time",
+ placeholder: "15:30",
+ description: "Time of day for recurring schedules",
+ validator: (value) => {
+ if (!value || value.trim() === "") return "Time is required";
+ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
+ if (!timeRegex.test(value)) return "Invalid time format (HH:MM)";
+ return null;
+ },
+ },
+ ];
+
+ // State for form inputs
+ const [formValues, setFormValues] = React.useState(
+ formFields.reduce((acc, field) => {
+ acc[field.id] = appState.configuration[field.id] || "";
+ return acc;
+ }, {})
+ );
+
+ const [errors, setErrors] = React.useState({});
+ const [focusedField, setFocusedField] = React.useState(0);
+ const [showValidation, setShowValidation] = React.useState(false);
+ const [nextRunTime, setNextRunTime] = React.useState(null);
+
+ // Validate all fields
+ const validateForm = () => {
+ const newErrors = {};
+ let isValid = true;
+
+ formFields.forEach((field) => {
+ const error = field.validator(formValues[field.id]);
+ if (error) {
+ newErrors[field.id] = error;
+ isValid = false;
+ }
+ });
+
+ setErrors(newErrors);
+ return isValid;
+ };
+
+ // Calculate next run time
+ const calculateNextRunTime = () => {
+ if (!formValues.scheduledTime) return null;
+
+ try {
+ const scheduledDate = new Date(formValues.scheduledTime);
+ const now = new Date();
+
+ // Format for display
+ const options = {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ };
+
+ return scheduledDate.toLocaleString(undefined, options);
+ } catch (error) {
+ return null;
+ }
+ };
+
+ // Handle keyboard input
+ useInput((input, key) => {
+ if (key.escape) {
+ // Go back to main menu
+ navigateBack();
+ } else if (key.tab || (key.tab && key.shift)) {
+ // Navigate between fields
+ if (key.shift) {
+ // Shift+Tab - previous field
+ setFocusedField((prev) =>
+ prev > 0 ? prev - 1 : formFields.length - 1
+ );
+ } else {
+ // Tab - next field
+ setFocusedField((prev) =>
+ prev < formFields.length - 1 ? prev + 1 : 0
+ );
+ }
+ } else if (key.return || key.enter) {
+ // Handle Enter key
+ if (focusedField === formFields.length - 1) {
+ // Last field (Save button) - save configuration
+ handleSave();
+ } else {
+ // Move to next field
+ setFocusedField((prev) =>
+ prev < formFields.length - 1 ? prev + 1 : 0
+ );
+ }
+ } else if (key.upArrow) {
+ // Navigate up
+ setFocusedField((prev) => (prev > 0 ? prev - 1 : formFields.length - 1));
+ } else if (key.downArrow) {
+ // Navigate down
+ setFocusedField((prev) => (prev < formFields.length - 1 ? prev + 1 : 0));
+ }
+ });
+
+ // Handle input changes
+ const handleInputChange = (fieldId, value) => {
+ setFormValues((prev) => ({
+ ...prev,
+ [fieldId]: value,
+ }));
+
+ // Clear error when user starts typing
+ if (errors[fieldId]) {
+ setErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[fieldId];
+ return newErrors;
+ });
+ }
+
+ // Update next run time when scheduled time changes
+ if (fieldId === "scheduledTime") {
+ setNextRunTime(calculateNextRunTime());
+ }
+ };
+
+ // Handle save configuration
+ const handleSave = () => {
+ if (validateForm()) {
+ // Update configuration
+ const config = {
+ ...formValues,
+ isScheduled: true,
+ };
+
+ updateConfiguration(config);
+ navigateBack();
+ } else {
+ // Show validation errors
+ setShowValidation(true);
+ }
+ };
+
+ // Handle test schedule
+ const handleTestSchedule = () => {
+ // Validate required fields
+ const requiredFields = ["scheduledTime"];
+ const tempErrors = {};
+ let hasError = false;
+
+ requiredFields.forEach((fieldId) => {
+ const field = formFields.find((f) => f.id === fieldId);
+ const error = field.validator(formValues[fieldId]);
+ if (error) {
+ tempErrors[fieldId] = error;
+ hasError = true;
+ }
+ });
+
+ if (hasError) {
+ setErrors(tempErrors);
+ setShowValidation(true);
+ return;
+ }
+
+ // Calculate and display next run time
+ setNextRunTime(calculateNextRunTime());
+ };
+
+ return React.createElement(
+ Box,
+ { flexDirection: "column", padding: 2, flexGrow: 1 },
+ // Header
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 2 },
+ React.createElement(Text, { bold: true, color: "cyan" }, "⏰ Scheduling"),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ "Configure when to run price update operations"
+ )
+ ),
+
+ // Schedule information
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "blue",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "blue" },
+ "Schedule Information:"
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Current Mode: ${appState.configuration.operationMode.toUpperCase()}`
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Target Tag: ${appState.configuration.targetTag || "Not set"}`
+ ),
+ React.createElement(
+ Text,
+ null,
+ ` Store: ${appState.configuration.shopDomain || "Not set"}`
+ )
+ )
+ ),
+
+ // Form fields
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 2 },
+ formFields.map((field, index) => {
+ const isFocused = focusedField === index;
+ const hasError = errors[field.id];
+ const currentValue = formValues[field.id];
+
+ return React.createElement(
+ Box,
+ {
+ key: field.id,
+ borderStyle: "single",
+ borderColor: hasError ? "red" : isFocused ? "blue" : "gray",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 1,
+ flexDirection: "column",
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center", marginBottom: 1 },
+ React.createElement(
+ Text,
+ {
+ bold: true,
+ color: isFocused ? "blue" : "white",
+ },
+ `${field.label}:`
+ ),
+ React.createElement(
+ Box,
+ { flexGrow: 1 },
+ React.createElement(
+ Text,
+ { color: "gray" },
+ ` ${field.description}`
+ )
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row" },
+ field.type === "select"
+ ? React.createElement(
+ Box,
+ { flexDirection: "column" },
+ field.options.map((option, optIndex) =>
+ React.createElement(
+ Box,
+ {
+ key: optIndex,
+ borderStyle:
+ formValues[field.id] === option.value
+ ? "single"
+ : "none",
+ borderColor: "blue",
+ paddingX: 2,
+ paddingY: 0.5,
+ marginBottom: 0.5,
+ backgroundColor:
+ formValues[field.id] === option.value
+ ? "blue"
+ : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color:
+ formValues[field.id] === option.value
+ ? "white"
+ : "gray",
+ },
+ option.label
+ )
+ )
+ )
+ )
+ : React.createElement(TextInput, {
+ value: currentValue,
+ placeholder: field.placeholder,
+ mask: null,
+ showCursor: isFocused,
+ focus: isFocused,
+ onChange: (value) => handleInputChange(field.id, value),
+ style: {
+ color: hasError ? "red" : isFocused ? "blue" : "white",
+ bold: isFocused,
+ },
+ })
+ ),
+ hasError &&
+ React.createElement(
+ Text,
+ { color: "red", italic: true },
+ ` Error: ${errors[field.id]}`
+ )
+ );
+ })
+ ),
+
+ // Next run time display
+ nextRunTime &&
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "green",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "green" },
+ "Next Scheduled Run:"
+ ),
+ React.createElement(Text, { color: "green" }, ` ${nextRunTime}`)
+ )
+ ),
+
+ // Action buttons
+ React.createElement(
+ Box,
+ { flexDirection: "row", justifyContent: "space-between" },
+ React.createElement(
+ Box,
+ { flexDirection: "column", width: "48%" },
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "gray",
+ paddingX: 2,
+ paddingY: 1,
+ alignItems: "center",
+ backgroundColor:
+ focusedField === formFields.length ? "yellow" : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color: focusedField === formFields.length ? "black" : "white",
+ bold: true,
+ },
+ focusedField === formFields.length ? "Testing..." : "Test Schedule"
+ )
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true, marginTop: 0.5 },
+ "Calculate next run time"
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "column", width: "48%" },
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "green",
+ paddingX: 2,
+ paddingY: 1,
+ alignItems: "center",
+ backgroundColor:
+ focusedField === formFields.length - 1 ? "green" : undefined,
+ },
+ React.createElement(
+ Text,
+ {
+ color: "white",
+ bold: true,
+ },
+ "Save & Exit"
+ )
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true, marginTop: 0.5 },
+ "Save schedule and return to menu"
+ )
+ )
+ ),
+
+ // Instructions
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 2,
+ borderTopStyle: "single",
+ borderColor: "gray",
+ paddingTop: 2,
+ },
+ React.createElement(Text, { color: "gray" }, "Navigation:"),
+ React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate fields"),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ " Tab/Shift+Tab - Next/Previous field"
+ ),
+ React.createElement(Text, { color: "gray" }, " Enter - Save/Next field"),
+ React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
+ ),
+
+ // Validation message
+ showValidation &&
+ Object.keys(errors).length > 0 &&
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 2,
+ padding: 1,
+ borderStyle: "single",
+ borderColor: "red",
+ },
+ React.createElement(
+ Text,
+ { color: "red", bold: true },
+ "Validation Errors:"
+ ),
+ React.createElement(
+ Text,
+ { color: "red" },
+ "Please fix the errors before saving."
+ )
+ )
+ );
+};
+
+module.exports = SchedulingScreen;
diff --git a/src/tui/components/screens/TagAnalysisScreen.jsx b/src/tui/components/screens/TagAnalysisScreen.jsx
new file mode 100644
index 0000000..a848654
--- /dev/null
+++ b/src/tui/components/screens/TagAnalysisScreen.jsx
@@ -0,0 +1,645 @@
+const React = require("react");
+const { Box, Text, useInput, useApp } = require("ink");
+const { useAppState } = require("../../providers/AppProvider.jsx");
+const SelectInput = require("ink-select-input").default;
+
+/**
+ * Tag Analysis Screen Component
+ * Analyzes product tags and provides insights for price update operations
+ * Requirements: 7.7, 7.8
+ */
+const TagAnalysisScreen = () => {
+ const { appState, navigateBack } = useAppState();
+ const { exit } = useApp();
+
+ // Mock tag analysis data
+ const mockTagAnalysis = {
+ totalProducts: 150,
+ tagCounts: [
+ { tag: "sale", count: 45, percentage: 30.0 },
+ { tag: "new", count: 32, percentage: 21.3 },
+ { tag: "featured", count: 28, percentage: 18.7 },
+ { tag: "clearance", count: 22, percentage: 14.7 },
+ { tag: "limited", count: 15, percentage: 10.0 },
+ { tag: "seasonal", count: 8, percentage: 5.3 },
+ ],
+ priceRanges: {
+ sale: { min: 9.99, max: 199.99, average: 59.5 },
+ new: { min: 19.99, max: 299.99, average: 89.75 },
+ featured: { min: 29.99, max: 399.99, average: 129.5 },
+ clearance: { min: 4.99, max: 149.99, average: 39.25 },
+ limited: { min: 49.99, max: 499.99, average: 199.5 },
+ seasonal: { min: 14.99, max: 249.99, average: 74.25 },
+ },
+ recommendations: [
+ {
+ type: "high_impact",
+ title: "High-Impact Tags",
+ description:
+ "Tags with many products that would benefit most from price updates",
+ tags: ["sale", "clearance"],
+ reason:
+ "These tags have the highest product counts and are most likely to need price adjustments",
+ },
+ {
+ type: "high_value",
+ title: "High-Value Tags",
+ description: "Tags with products having higher average prices",
+ tags: ["limited", "featured"],
+ reason:
+ "These tags contain premium products where price adjustments have the most financial impact",
+ },
+ {
+ type: "caution",
+ title: "Use Caution",
+ description: "Tags that may require special handling",
+ tags: ["new", "seasonal"],
+ reason:
+ "These tags may have products with special pricing strategies that shouldn't be automatically adjusted",
+ },
+ ],
+ };
+
+ // State for tag analysis
+ const [analysisData, setAnalysisData] = React.useState(mockTagAnalysis);
+ const [selectedTag, setSelectedTag] = React.useState(null);
+ const [showDetails, setShowDetails] = React.useState(false);
+ const [analysisType, setAnalysisType] = React.useState("overview");
+
+ // Analysis type options
+ const analysisOptions = [
+ { value: "overview", label: "Overview" },
+ { value: "distribution", label: "Tag Distribution" },
+ { value: "pricing", label: "Pricing Analysis" },
+ { value: "recommendations", label: "Recommendations" },
+ ];
+
+ // Handle keyboard input
+ useInput((input, key) => {
+ if (key.escape) {
+ // Go back to main menu
+ navigateBack();
+ } else if (key.upArrow) {
+ // Navigate up in list
+ if (selectedTag > 0) {
+ setSelectedTag(selectedTag - 1);
+ setShowDetails(false);
+ }
+ } else if (key.downArrow) {
+ // Navigate down in list
+ if (selectedTag < analysisData.tagCounts.length - 1) {
+ setSelectedTag(selectedTag + 1);
+ setShowDetails(false);
+ }
+ } else if (key.return || key.enter) {
+ // Toggle tag details
+ if (selectedTag !== null) {
+ setShowDetails(!showDetails);
+ }
+ } else if (key.r) {
+ // Refresh analysis
+ setAnalysisData(mockTagAnalysis);
+ setSelectedTag(null);
+ setShowDetails(false);
+ }
+ });
+
+ // Handle analysis type change
+ const handleAnalysisTypeChange = (option) => {
+ setAnalysisType(option.value);
+ };
+
+ // Get tag color based on count
+ const getTagColor = (count) => {
+ if (count >= 40) return "red";
+ if (count >= 25) return "yellow";
+ if (count >= 15) return "blue";
+ return "green";
+ };
+
+ // Render overview section
+ const renderOverview = () =>
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "blue",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "blue" },
+ "Tag Analysis Overview"
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ `Total Products Analyzed: ${analysisData.totalProducts}`
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ `Unique Tags Found: ${analysisData.tagCounts.length}`
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ `Most Common Tag: ${analysisData.tagCounts[0]?.tag || "N/A"} (${
+ analysisData.tagCounts[0]?.count || 0
+ } products)`
+ )
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "cyan", marginBottom: 1 },
+ "Tag Distribution:"
+ ),
+ analysisData.tagCounts.map((tagInfo, index) => {
+ const isSelected = selectedTag === index;
+ const barWidth = Math.round(
+ (tagInfo.count / analysisData.totalProducts) * 40
+ );
+
+ return React.createElement(
+ Box,
+ {
+ key: index,
+ borderStyle: "single",
+ borderColor: isSelected ? "blue" : "transparent",
+ paddingX: 1,
+ paddingY: 0.5,
+ marginBottom: 0.5,
+ backgroundColor: isSelected ? "blue" : undefined,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center", width: "100%" },
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "white",
+ bold: true,
+ width: 15,
+ },
+ tagInfo.tag
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "gray",
+ width: 8,
+ },
+ `${tagInfo.count}`
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "gray",
+ width: 6,
+ },
+ `${tagInfo.percentage.toFixed(1)}%`
+ ),
+ React.createElement(
+ Text,
+ {
+ color: getTagColor(tagInfo.count),
+ flexGrow: 1,
+ },
+ "█".repeat(barWidth) + "░".repeat(40 - barWidth)
+ )
+ )
+ );
+ })
+ )
+ );
+
+ // Render pricing analysis
+ const renderPricingAnalysis = () =>
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "cyan", marginBottom: 1 },
+ "Price Analysis by Tag:"
+ ),
+ analysisData.tagCounts.map((tagInfo, index) => {
+ const priceRange = analysisData.priceRanges[tagInfo.tag];
+ if (!priceRange) return null;
+
+ const isSelected = selectedTag === index;
+
+ return React.createElement(
+ Box,
+ {
+ key: index,
+ borderStyle: "single",
+ borderColor: isSelected ? "blue" : "gray",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 1,
+ backgroundColor: isSelected ? "blue" : undefined,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "white",
+ bold: true,
+ },
+ tagInfo.tag
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "gray",
+ fontSize: "small",
+ },
+ `${tagInfo.count} products`
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", marginTop: 1 },
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "cyan",
+ bold: true,
+ },
+ "Range: "
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "white",
+ },
+ `$${priceRange.min} - $${priceRange.max}`
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row" },
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "cyan",
+ bold: true,
+ },
+ "Avg: "
+ ),
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "white" : "white",
+ },
+ `$${priceRange.average.toFixed(2)}`
+ )
+ )
+ )
+ );
+ })
+ );
+
+ // Render recommendations
+ const renderRecommendations = () =>
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "cyan", marginBottom: 1 },
+ "Recommendations:"
+ ),
+ analysisData.recommendations.map((rec, index) => {
+ const getTypeColor = (type) => {
+ switch (type) {
+ case "high_impact":
+ return "green";
+ case "high_value":
+ return "blue";
+ case "caution":
+ return "yellow";
+ default:
+ return "white";
+ }
+ };
+
+ const getTypeIcon = (type) => {
+ switch (type) {
+ case "high_impact":
+ return "⭐";
+ case "high_value":
+ return "💎";
+ case "caution":
+ return "⚠️";
+ default:
+ return "ℹ️";
+ }
+ };
+
+ return React.createElement(
+ Box,
+ {
+ key: index,
+ borderStyle: "single",
+ borderColor: getTypeColor(rec.type),
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 1,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center", marginBottom: 1 },
+ React.createElement(
+ Text,
+ {
+ color: getTypeColor(rec.type),
+ bold: true,
+ },
+ `${getTypeIcon(rec.type)} ${rec.title}`
+ )
+ ),
+ React.createElement(
+ Text,
+ { color: "white", marginBottom: 1 },
+ rec.description
+ ),
+ React.createElement(
+ Text,
+ { color: "gray", italic: true, marginBottom: 1 },
+ rec.reason
+ ),
+ React.createElement(
+ Text,
+ { color: "cyan", bold: true },
+ "Tags: " + rec.tags.join(", ")
+ )
+ )
+ );
+ })
+ );
+
+ // Render current analysis view
+ const renderCurrentView = () => {
+ switch (analysisType) {
+ case "overview":
+ return renderOverview();
+ case "pricing":
+ return renderPricingAnalysis();
+ case "recommendations":
+ return renderRecommendations();
+ default:
+ return renderOverview();
+ }
+ };
+
+ return React.createElement(
+ Box,
+ { flexDirection: "column", padding: 2, flexGrow: 1 },
+ // Header
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginBottom: 2 },
+ React.createElement(
+ Text,
+ { bold: true, color: "cyan" },
+ "🏷️ Tag Analysis"
+ ),
+ React.createElement(
+ Text,
+ { color: "gray" },
+ "Analyze product tags to optimize price update operations"
+ )
+ ),
+
+ // Analysis type selector
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "blue",
+ paddingX: 1,
+ paddingY: 1,
+ marginBottom: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "blue" },
+ "Analysis Type:"
+ ),
+ React.createElement(SelectInput, {
+ items: analysisOptions,
+ selectedIndex: analysisOptions.findIndex(
+ (opt) => opt.value === analysisType
+ ),
+ onSelect: handleAnalysisTypeChange,
+ itemComponent: ({ label, isSelected }) =>
+ React.createElement(
+ Text,
+ {
+ color: isSelected ? "blue" : "white",
+ bold: isSelected,
+ },
+ label
+ ),
+ })
+ )
+ ),
+
+ // Current analysis view
+ React.createElement(
+ Box,
+ {
+ flexGrow: 1,
+ borderStyle: "single",
+ borderColor: "gray",
+ flexDirection: "column",
+ padding: 1,
+ },
+ renderCurrentView()
+ ),
+
+ // Tag details (when selected)
+ showDetails &&
+ selectedTag !== null &&
+ analysisData.tagCounts[selectedTag] &&
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "green",
+ paddingX: 1,
+ paddingY: 1,
+ marginTop: 2,
+ },
+ React.createElement(
+ Box,
+ { flexDirection: "column" },
+ React.createElement(
+ Text,
+ { bold: true, color: "green" },
+ "Tag Details:"
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(Text, { color: "white", bold: true }, "Tag: "),
+ React.createElement(
+ Text,
+ { color: "white" },
+ analysisData.tagCounts[selectedTag].tag
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ { color: "white", bold: true },
+ "Product Count: "
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ analysisData.tagCounts[selectedTag].count
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ { color: "white", bold: true },
+ "Percentage: "
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ `${analysisData.tagCounts[selectedTag].percentage.toFixed(1)}%`
+ )
+ ),
+ analysisData.priceRanges[analysisData.tagCounts[selectedTag].tag] &&
+ React.createElement(
+ Box,
+ { flexDirection: "column", marginTop: 1 },
+ React.createElement(
+ Text,
+ { color: "white", bold: true },
+ "Pricing Information:"
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ { color: "cyan", bold: true },
+ "Min: "
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ `$${
+ analysisData.priceRanges[
+ analysisData.tagCounts[selectedTag].tag
+ ].min
+ }`
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ { color: "cyan", bold: true },
+ "Max: "
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ `$${
+ analysisData.priceRanges[
+ analysisData.tagCounts[selectedTag].tag
+ ].max
+ }`
+ )
+ ),
+ React.createElement(
+ Box,
+ { flexDirection: "row", alignItems: "center" },
+ React.createElement(
+ Text,
+ { color: "cyan", bold: true },
+ "Average: "
+ ),
+ React.createElement(
+ Text,
+ { color: "white" },
+ `$${analysisData.priceRanges[
+ analysisData.tagCounts[selectedTag].tag
+ ].average.toFixed(2)}`
+ )
+ )
+ )
+ )
+ ),
+
+ // Instructions
+ React.createElement(
+ Box,
+ {
+ flexDirection: "column",
+ marginTop: 2,
+ borderTopStyle: "single",
+ borderColor: "gray",
+ paddingTop: 2,
+ },
+ React.createElement(Text, { color: "gray" }, "Controls:"),
+ React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate items"),
+ React.createElement(Text, { color: "gray" }, " Enter - View details"),
+ React.createElement(Text, { color: "gray" }, " R - Refresh analysis"),
+ React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
+ ),
+
+ // Status bar
+ React.createElement(
+ Box,
+ {
+ borderStyle: "single",
+ borderColor: "gray",
+ paddingX: 1,
+ paddingY: 0.5,
+ marginTop: 1,
+ },
+ React.createElement(
+ Text,
+ { color: "gray", fontSize: "small" },
+ `View: ${
+ analysisOptions.find((opt) => opt.value === analysisType)?.label ||
+ "Overview"
+ } | Selected: ${selectedTag !== null ? `#${selectedTag + 1}` : "None"}`
+ )
+ )
+ );
+};
+
+module.exports = TagAnalysisScreen;
diff --git a/src/tui/providers/AppProvider.jsx b/src/tui/providers/AppProvider.jsx
index ea461e9..6dbc833 100644
--- a/src/tui/providers/AppProvider.jsx
+++ b/src/tui/providers/AppProvider.jsx
@@ -14,7 +14,7 @@ const initialState = {
currentScreen: "main-menu",
navigationHistory: [],
configuration: {
- shopifyDomain: "",
+ shopDomain: "",
accessToken: "",
targetTag: "",
priceAdjustment: 0,