From 66b7e4227519661e79e0f947575f0c06ea58ea64 Mon Sep 17 00:00:00 2001 From: Spencer Grimes Date: Sun, 10 Aug 2025 16:08:21 -0500 Subject: [PATCH] Cleaned up extra files --- .aicodeprep-gui | 55 - fullcode.txt | 16032 ---------------------------------------------- 2 files changed, 16087 deletions(-) delete mode 100644 .aicodeprep-gui delete mode 100644 fullcode.txt diff --git a/.aicodeprep-gui b/.aicodeprep-gui deleted file mode 100644 index 9f84663..0000000 --- a/.aicodeprep-gui +++ /dev/null @@ -1,55 +0,0 @@ -# .aicodeprep-gui LLM/AI context helper settings file -# This file stores your preferences (checked code files, window size) for this folder. -# Generated by aicodeprep-gui. -# Homepage: https://wuu73.org/aicp -# GitHub: https://github.com/detroittommy879/aicodeprep-gui -# ---------------------------------------------------------- -# aicodeprep-gui preferences file version 1.0 -version=1.0 - -[window] -width=1920 -height=1009 -splitter_state=AAAA/wAAAAEAAAACAAADAAAAAPwB/////wEAAAACAA== - -[format] -output_format=xml - -[files] -.env.example -package.json -README.md -.kiro\specs\windows-compatible-tui\design.md -.kiro\specs\windows-compatible-tui\requirements.md -.kiro\specs\windows-compatible-tui\tasks.md -.kiro\steering\product.md -.kiro\steering\structure.md -.kiro\steering\tech.md -backend\.env -docs\enhanced-signal-handling.md -src\index.js -src\tui-entry.js -src\config\environment.js -src\services\product.js -src\services\progress.js -src\services\schedule.js -src\services\shopify.js -src\tui\TuiApplication.jsx -src\tui\components\Router.jsx -src\tui\components\StatusBar.jsx -src\tui\components\common\ProgressBar.js -src\tui\providers\AppProvider.jsx -src\utils\logger.js -src\utils\price.js -tests\index.test.js -tests\config\environment.test.js -tests\integration\rollback-workflow.test.js -tests\integration\scheduled-execution-workflow.test.js -tests\services\product.test.js -tests\services\progress.test.js -tests\services\schedule-error-handling.test.js -tests\services\schedule-signal-handling.test.js -tests\services\schedule.test.js -tests\services\shopify.test.js -tests\utils\logger.test.js -tests\utils\price.test.js diff --git a/fullcode.txt b/fullcode.txt deleted file mode 100644 index fa77ba5..0000000 --- a/fullcode.txt +++ /dev/null @@ -1,16032 +0,0 @@ -.kiro\specs\windows-compatible-tui\design.md: - -# Design Document - -## Overview - -This design document outlines the replacement of the Blessed-based TUI with a Windows-compatible alternative using **Ink** (React for CLI) as the primary library choice. Ink provides excellent cross-platform support, modern React-based component architecture, and superior Windows compatibility compared to Blessed. The design maintains all existing functionality while improving performance, maintainability, and user experience across all platforms. - -## Architecture - -### Library Selection: Ink (React for CLI) - -**Primary Choice: Ink v4.x** - -- **Rationale**: Ink is built on React principles, providing a modern component-based architecture -- **Windows Compatibility**: Excellent support for Windows Terminal, Command Prompt, and PowerShell -- **Performance**: Uses React's reconciliation for efficient updates, reducing flicker -- **Ecosystem**: Large ecosystem of pre-built components and utilities -- **Maintenance**: Actively maintained by Vercel with strong community support - -**Alternative Considerations**: - -- **Blessed**: Current library with Windows issues (being replaced) -- **Terminal-kit**: Good Windows support but more complex API -- **Enquirer**: Limited to prompts, not full TUI applications -- **Neo-blessed**: Fork of Blessed with some improvements but still has Windows issues - -### Component Architecture - -``` -TuiApplication (Root) -├── AppProvider (Context/State Management) -├── Router (Screen Management) -├── StatusBar (Global Status) -└── Screens/ - ├── MainMenuScreen - ├── ConfigurationScreen - ├── OperationScreen - ├── SchedulingScreen - ├── LogViewerScreen - └── TagAnalysisScreen -``` - -### State Management - -Using React Context API with custom hooks for: - -- Application state (current screen, navigation history) -- Configuration state (environment variables, settings) -- Operation state (progress, results, errors) -- UI state (focus, selections, modal states) - -## Components and Interfaces - -### Core Components - -#### 1. TuiApplication (Root Component) - -```javascript -const TuiApplication = () => { - return ( - - - - - - - ); -}; -``` - -#### 2. AppProvider (State Management) - -```javascript -const AppProvider = ({ children }) => { - const [appState, setAppState] = useState({ - currentScreen: "main-menu", - navigationHistory: [], - configuration: {}, - operationState: null, - }); - - return ( - - {children} - - ); -}; -``` - -#### 3. Router (Screen Management) - -```javascript -const Router = () => { - const { appState } = useContext(AppContext); - - const screens = { - "main-menu": MainMenuScreen, - configuration: ConfigurationScreen, - operation: OperationScreen, - scheduling: SchedulingScreen, - logs: LogViewerScreen, - "tag-analysis": TagAnalysisScreen, - }; - - const CurrentScreen = screens[appState.currentScreen]; - return ; -}; -``` - -#### 4. StatusBar (Global Status Display) - -```javascript -const StatusBar = () => { - const { connectionStatus, operationProgress } = useAppState(); - - return ( - - ● Connected - | - Progress: {operationProgress}% - - ); -}; -``` - -### Screen Components - -#### MainMenuScreen - -- Navigation menu with keyboard shortcuts -- Current configuration summary -- Quick action buttons -- Help information - -#### ConfigurationScreen - -- Environment variable editor -- Input validation with real-time feedback -- API connection testing -- Save/cancel operations - -#### OperationScreen - -- Operation type selection (update/rollback) -- Real-time progress display -- Product processing information -- Error handling and display - -#### SchedulingScreen - -- Date/time picker interface -- Schedule management -- Countdown display -- Cancellation controls - -#### LogViewerScreen - -- Paginated log display -- Search and filtering -- Log entry details -- Export functionality - -#### TagAnalysisScreen - -- Tag listing and statistics -- Product count per tag -- Sample product display -- Recommendations - -### Reusable UI Components - -#### ProgressBar - -```javascript -const ProgressBar = ({ progress, label, color = "blue" }) => { - const width = 40; - const filled = Math.round((progress / 100) * width); - - return ( - - {label} - - {"█".repeat(filled)} - {"░".repeat(width - filled)} - {progress}% - - - ); -}; -``` - -#### InputField - -```javascript -const InputField = ({ label, value, onChange, validation, placeholder }) => { - const [isValid, setIsValid] = useState(true); - - return ( - - {label}: - { - onChange(val); - setIsValid(validation ? validation(val) : true); - }} - placeholder={placeholder} - /> - {!isValid && Invalid input} - - ); -}; -``` - -#### MenuList - -```javascript -const MenuList = ({ items, selectedIndex, onSelect }) => { - return ( - - {items.map((item, index) => ( - - - {index === selectedIndex ? "► " : " "} - {item.label} - - - ))} - - ); -}; -``` - -## Data Models - -### Application State - -```javascript -interface AppState { - currentScreen: string; - navigationHistory: string[]; - configuration: ConfigurationState; - operationState: OperationState | null; - uiState: UIState; -} - -interface ConfigurationState { - shopifyDomain: string; - accessToken: string; - targetTag: string; - priceAdjustment: number; - operationMode: "update" | "rollback"; - isValid: boolean; - lastTested: Date | null; -} - -interface OperationState { - type: "update" | "rollback" | "scheduled"; - status: "idle" | "running" | "completed" | "error"; - progress: number; - currentProduct: string | null; - results: OperationResults | null; - errors: Error[]; -} - -interface UIState { - focusedComponent: string; - modalOpen: boolean; - selectedMenuIndex: number; - scrollPosition: number; -} -``` - -### Service Integration - -```javascript -interface ServiceIntegration { - shopifyService: ShopifyService; - productService: ProductService; - progressService: ProgressService; - configService: ConfigurationService; -} -``` - -## Error Handling - -### Error Categories - -1. **Configuration Errors**: Invalid environment variables, API credentials -2. **Network Errors**: Connection failures, timeout issues -3. **API Errors**: Shopify API rate limits, authentication failures -4. **UI Errors**: Component rendering issues, state inconsistencies -5. **System Errors**: File system access, permission issues - -### Error Display Strategy - -```javascript -const ErrorBoundary = ({ children }) => { - const [error, setError] = useState(null); - - if (error) { - return ( - - - Error Occurred - - {error.message} - Press 'r' to retry or 'q' to quit - - ); - } - - return children; -}; -``` - -### Graceful Degradation - -- Fallback to basic text display if advanced features fail -- Automatic retry mechanisms for network operations -- State persistence to recover from crashes -- Clear error messages with suggested actions - -## Testing Strategy - -### Component Testing - -```javascript -// Example test using Ink's testing utilities -import { render } from "ink-testing-library"; -import { MainMenuScreen } from "../screens/MainMenuScreen"; - -test("renders main menu with correct options", () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain("Price Update Operations"); - expect(lastFrame()).toContain("Configuration"); - expect(lastFrame()).toContain("View Logs"); -}); -``` - -### Integration Testing - -- Test service integration with mock services -- Verify state management across screen transitions -- Test keyboard navigation and input handling -- Validate error handling scenarios - -### Cross-Platform Testing - -- Automated testing on Windows, macOS, and Linux -- Terminal compatibility testing (Windows Terminal, Command Prompt, PowerShell) -- Unicode and color support verification -- Performance testing with large datasets - -## Migration Strategy - -### Phase 1: Setup and Core Infrastructure - -1. Install Ink and related dependencies -2. Create basic application structure -3. Implement state management system -4. Set up routing and navigation - -### Phase 2: Screen Implementation - -1. Implement MainMenuScreen (simplest) -2. Create ConfigurationScreen with form handling -3. Build OperationScreen with progress display -4. Add remaining screens (Scheduling, Logs, TagAnalysis) - -### Phase 3: Component Migration - -1. Replace Blessed ProgressBar with Ink version -2. Migrate form components and input handling -3. Update navigation and keyboard shortcuts -4. Implement error handling and validation - -### Phase 4: Testing and Refinement - -1. Comprehensive testing on Windows systems -2. Performance optimization and bug fixes -3. Documentation updates -4. Legacy code cleanup - -### Dependency Changes - -```json -{ - "dependencies": { - "ink": "^4.4.1", - "react": "^18.2.0", - "@ink/text-input": "^5.0.1", - "@ink/select-input": "^5.0.1", - "@ink/spinner": "^5.0.1" - }, - "devDependencies": { - "ink-testing-library": "^3.0.0" - } -} -``` - -### File Structure Changes - -``` -src/ -├── tui/ -│ ├── components/ -│ │ ├── common/ -│ │ │ ├── ProgressBar.jsx -│ │ │ ├── InputField.jsx -│ │ │ ├── MenuList.jsx -│ │ │ └── ErrorBoundary.jsx -│ │ ├── screens/ -│ │ │ ├── MainMenuScreen.jsx -│ │ │ ├── ConfigurationScreen.jsx -│ │ │ ├── OperationScreen.jsx -│ │ │ ├── SchedulingScreen.jsx -│ │ │ ├── LogViewerScreen.jsx -│ │ │ └── TagAnalysisScreen.jsx -│ │ └── providers/ -│ │ ├── AppProvider.jsx -│ │ └── ServiceProvider.jsx -│ ├── hooks/ -│ │ ├── useAppState.js -│ │ ├── useNavigation.js -│ │ └── useServices.js -│ ├── utils/ -│ │ ├── keyboardHandlers.js -│ │ └── validation.js -│ └── TuiApplication.jsx -└── tui-entry.js (new entry point) -``` - -## Performance Considerations - -### Rendering Optimization - -- Use React.memo for expensive components -- Implement virtual scrolling for large lists -- Debounce rapid state updates -- Minimize re-renders with proper state structure - -### Memory Management - -- Clean up event listeners and timers -- Implement proper component unmounting -- Use weak references for large data structures -- Monitor memory usage during long operations - -### Windows-Specific Optimizations - -- Use Windows-compatible Unicode characters -- Optimize for Windows Terminal performance -- Handle Windows-specific keyboard events -- Ensure proper color rendering in different terminals - -## Security Considerations - -### Input Validation - -- Sanitize all user inputs -- Validate configuration values -- Prevent injection attacks through input fields -- Secure handling of API credentials - -### State Security - -- Encrypt sensitive data in state -- Clear sensitive information on exit -- Prevent credential logging -- Secure temporary file handling - -This design provides a robust foundation for replacing Blessed with Ink, ensuring excellent Windows compatibility while maintaining all existing functionality and improving the overall user experience. - - - -.kiro\specs\windows-compatible-tui\requirements.md: - -# Requirements Document - -## Introduction - -This document outlines the requirements for replacing the existing Blessed-based Terminal User Interface (TUI) with a Windows-compatible alternative. The current TUI implementation using the Blessed library has compatibility issues on Windows systems, requiring a migration to a more robust, cross-platform TUI library that provides better Windows support while maintaining all existing functionality and user experience expectations. - -## Requirements - -### Requirement 1 - -**User Story:** As a Windows user, I want a TUI that works reliably on my system, so that I can use the interactive interface without compatibility issues. - -#### Acceptance Criteria - -1. WHEN the TUI is launched on Windows THEN the system SHALL display correctly without rendering artifacts -2. WHEN using Windows Terminal or Command Prompt THEN the system SHALL handle keyboard input properly -3. WHEN the interface renders THEN the system SHALL display Unicode characters and colors correctly on Windows -4. WHEN resizing the terminal window THEN the system SHALL adapt the layout appropriately -5. WHEN using different Windows terminal emulators THEN the system SHALL maintain consistent behavior - -### Requirement 2 - -**User Story:** As a developer, I want to replace Blessed with a better cross-platform TUI library, so that the application works consistently across all operating systems. - -#### Acceptance Criteria - -1. WHEN selecting a replacement library THEN the system SHALL prioritize Windows compatibility -2. WHEN the new library is integrated THEN the system SHALL maintain feature parity with the Blessed implementation -3. WHEN the library is chosen THEN the system SHALL have active maintenance and good documentation -4. WHEN implementing the replacement THEN the system SHALL support modern terminal features -5. WHEN the migration is complete THEN the system SHALL remove all Blessed dependencies - -### Requirement 3 - -**User Story:** As a user, I want the same TUI functionality after the library replacement, so that my workflow remains unchanged. - -#### Acceptance Criteria - -1. WHEN the new TUI loads THEN the system SHALL display the same main menu structure -2. WHEN navigating the interface THEN the system SHALL support the same keyboard shortcuts -3. WHEN configuring settings THEN the system SHALL provide the same configuration options -4. WHEN running operations THEN the system SHALL show the same progress indicators -5. WHEN viewing logs THEN the system SHALL display the same information format - -### Requirement 4 - -**User Story:** As a user, I want improved performance and responsiveness in the new TUI, so that the interface feels more fluid and responsive. - -#### Acceptance Criteria - -1. WHEN the TUI starts THEN the system SHALL load faster than the Blessed version -2. WHEN updating progress displays THEN the system SHALL render smoothly without flickering -3. WHEN handling large amounts of log data THEN the system SHALL maintain responsive scrolling -4. WHEN switching between screens THEN the system SHALL transition quickly -5. WHEN processing user input THEN the system SHALL respond immediately - -### Requirement 5 - -**User Story:** As a developer, I want the new TUI implementation to follow modern JavaScript patterns, so that the code is maintainable and extensible. - -#### Acceptance Criteria - -1. WHEN implementing components THEN the system SHALL use ES6+ features and modern patterns -2. WHEN structuring the code THEN the system SHALL follow the existing project architecture -3. WHEN handling state THEN the system SHALL use clear state management patterns -4. WHEN implementing event handling THEN the system SHALL use consistent event patterns -5. WHEN writing tests THEN the system SHALL provide good test coverage for TUI components - -### Requirement 6 - -**User Story:** As a user, I want enhanced visual feedback and better error handling in the new TUI, so that I have a clearer understanding of system status. - -#### Acceptance Criteria - -1. WHEN errors occur THEN the system SHALL display more informative error messages -2. WHEN operations are running THEN the system SHALL provide clearer progress visualization -3. WHEN configuration is invalid THEN the system SHALL highlight specific issues -4. WHEN API calls fail THEN the system SHALL show detailed connection status -5. WHEN the system is busy THEN the system SHALL provide appropriate loading indicators - -### Requirement 7 - -**User Story:** As a developer, I want the migration to preserve all existing service integrations, so that business logic remains unchanged. - -#### Acceptance Criteria - -1. WHEN the new TUI is implemented THEN the system SHALL reuse existing ShopifyService without changes -2. WHEN operations run THEN the system SHALL use existing ProductService and ProgressService -3. WHEN configuration is managed THEN the system SHALL use existing environment configuration -4. WHEN logs are generated THEN the system SHALL maintain compatibility with existing log formats -5. WHEN the migration is complete THEN the system SHALL pass all existing integration tests - -### Requirement 8 - -**User Story:** As a user, I want better accessibility features in the new TUI, so that the interface is more inclusive and easier to use. - -#### Acceptance Criteria - -1. WHEN using screen readers THEN the system SHALL provide appropriate text descriptions -2. WHEN using high contrast mode THEN the system SHALL adapt color schemes appropriately -3. WHEN using keyboard-only navigation THEN the system SHALL provide clear focus indicators -4. WHEN text is displayed THEN the system SHALL support different font sizes and terminal settings -5. WHEN colors are used THEN the system SHALL ensure sufficient contrast ratios - -### Requirement 9 - -**User Story:** As a developer, I want comprehensive documentation for the new TUI library choice, so that future maintenance is straightforward. - -#### Acceptance Criteria - -1. WHEN the library is selected THEN the system SHALL document the selection rationale -2. WHEN implementation patterns are established THEN the system SHALL document coding conventions -3. WHEN components are created THEN the system SHALL include inline documentation -4. WHEN the migration is complete THEN the system SHALL update all relevant README files -5. WHEN troubleshooting guides are needed THEN the system SHALL provide Windows-specific guidance - -### Requirement 10 - -**User Story:** As a user, I want the new TUI to handle terminal resizing and different screen sizes better, so that I can use it on various display configurations. - -#### Acceptance Criteria - -1. WHEN the terminal is resized THEN the system SHALL automatically adjust layout proportions -2. WHEN using small terminal windows THEN the system SHALL provide appropriate scrolling -3. WHEN using large displays THEN the system SHALL utilize available space effectively -4. WHEN switching between portrait and landscape orientations THEN the system SHALL adapt accordingly -5. WHEN minimum size requirements aren't met THEN the system SHALL display helpful guidance - -### Requirement 11 - -**User Story:** As a developer, I want a smooth migration path from Blessed to the new library, so that the transition minimizes disruption. - -#### Acceptance Criteria - -1. WHEN planning the migration THEN the system SHALL identify all Blessed-specific code -2. WHEN implementing replacements THEN the system SHALL maintain API compatibility where possible -3. WHEN testing the migration THEN the system SHALL verify functionality on multiple Windows versions -4. WHEN deploying the changes THEN the system SHALL provide fallback options if issues arise -5. WHEN the migration is complete THEN the system SHALL clean up all legacy Blessed code - -### Requirement 12 - -**User Story:** As a user, I want the new TUI to support modern terminal features, so that I can take advantage of enhanced terminal capabilities. - -#### Acceptance Criteria - -1. WHEN using modern terminals THEN the system SHALL support true color (24-bit) display -2. WHEN terminals support it THEN the system SHALL use enhanced Unicode characters -3. WHEN available THEN the system SHALL support mouse interaction for navigation -4. WHEN terminals provide it THEN the system SHALL use improved cursor positioning -5. WHEN modern features are unavailable THEN the system SHALL gracefully degrade functionality - - - -.kiro\specs\windows-compatible-tui\tasks.md: - -# Implementation Plan - -- [ ] 1. Setup Ink infrastructure and remove Blessed dependencies - - - Remove blessed dependency from package.json and install Ink dependencies - - Create new TUI entry point file that initializes Ink application - - Set up basic React component structure with JSX support - - _Requirements: 2.2, 2.5_ - -- [ ] 2. Implement core application structure and state management - - - Create AppProvider component with React Context for global state management - - Implement Router component for screen navigation and history management - - Create useAppState and useNavigation custom hooks for state access - - Write unit tests for state management and navigation logic - - _Requirements: 5.1, 5.3, 7.1_ - -- [ ] 3. Build reusable UI components -- [ ] 3.1 Create ProgressBar component with Ink - - - Replace Blessed ProgressBar with Ink-based implementation using Box and Text components - - Add support for different colors, labels, and progress indicators - - Write unit tests for ProgressBar component rendering and updates - - _Requirements: 3.1, 4.2, 6.2_ - -- [ ] 3.2 Implement InputField component with validation - - - Create InputField component using Ink's TextInput with validation support - - Add real-time validation feedback and error message display - - Write unit tests for input validation and error handling - - _Requirements: 3.2, 6.3, 8.3_ - -- [ ] 3.3 Create MenuList component for navigation - - - Implement MenuList component with keyboard navigation support - - Add selection highlighting and keyboard shortcut display - - Write unit tests for menu navigation and selection handling - - _Requirements: 1.2, 9.3, 9.4_ - -- [ ] 3.4 Build ErrorBoundary component for error handling - - - Create ErrorBoundary component to catch and display React errors gracefully - - Implement error recovery mechanisms and user-friendly error messages - - Write unit tests for error boundary functionality - - _Requirements: 6.1, 10.4, 11.4_ - -- [ ] 4. Implement StatusBar component - - - Create StatusBar component showing connection status and operation progress - - Integrate with existing services to display real-time system status - - Add support for different status indicators and colors - - Write unit tests for status display and updates - - _Requirements: 8.1, 8.2, 8.3_ - -- [ ] 5. Create MainMenuScreen component - - - Implement MainMenuScreen as the primary navigation interface - - Add keyboard shortcuts and menu options matching existing TUI requirements - - Integrate with navigation system for screen transitions - - Write unit tests for menu functionality and navigation - - _Requirements: 1.1, 1.3, 3.1, 9.1_ - -- [ ] 6. Build ConfigurationScreen component -- [ ] 6.1 Create configuration form interface - - - Implement ConfigurationScreen with form fields for all environment variables - - Add input validation and real-time feedback for configuration values - - Write unit tests for form validation and state management - - _Requirements: 2.1, 2.2, 2.4_ - -- [ ] 6.2 Implement configuration persistence - - - Add functionality to save configuration changes to .env file - - Implement configuration loading and validation on screen load - - Write unit tests for configuration file operations - - _Requirements: 2.3, 7.4, 11.4_ - -- [ ] 6.3 Add API connection testing - - - Integrate Shopify API connection testing within configuration screen - - Display connection status and error messages for failed connections - - Write unit tests for API connection testing functionality - - _Requirements: 2.5, 6.4, 8.1_ - -- [ ] 7. Implement OperationScreen component -- [ ] 7.1 Create operation selection interface - - - Build OperationScreen with update/rollback operation selection - - Display current configuration summary before operation execution - - Write unit tests for operation selection and configuration display - - _Requirements: 3.1, 4.1, 7.2_ - -- [ ] 7.2 Add real-time progress display - - - Implement real-time progress indicators using ProgressBar component - - Display current product information and processing status - - Write unit tests for progress display and updates - - _Requirements: 3.2, 3.3, 4.2, 8.2_ - -- [ ] 7.3 Integrate operation results display - - - Add results summary display for completed operations - - Implement error display panel for operation failures - - Write unit tests for results display and error handling - - _Requirements: 3.4, 3.5, 4.3, 6.1_ - -- [ ] 8. Build SchedulingScreen component -- [ ] 8.1 Create scheduling interface - - - Implement SchedulingScreen with date/time picker functionality - - Add schedule management and countdown timer display - - Write unit tests for scheduling interface and timer functionality - - _Requirements: 5.1, 5.2, 5.3_ - -- [ ] 8.2 Add schedule cancellation and notifications - - - Implement schedule cancellation with confirmation dialog - - Add visual notifications for approaching scheduled operations - - Write unit tests for cancellation and notification systems - - _Requirements: 5.4, 5.5_ - -- [ ] 9. Create LogViewerScreen component -- [ ] 9.1 Implement log display with pagination - - - Build LogViewerScreen with paginated log entry display - - Add scrolling support for large log files - - Write unit tests for log display and pagination - - _Requirements: 6.1, 6.4, 10.3_ - -- [ ] 9.2 Add log filtering and search functionality - - - Implement search and filtering capabilities for log entries - - Add detailed view for selected log entries - - Write unit tests for search and filtering functionality - - _Requirements: 6.2, 6.3_ - -- [ ] 9.3 Integrate automatic log refresh - - - Add automatic refresh functionality for active log monitoring - - Implement efficient update mechanisms to avoid performance issues - - Write unit tests for automatic refresh and performance - - _Requirements: 6.5, 4.3_ - -- [ ] 10. Build TagAnalysisScreen component -- [ ] 10.1 Create tag analysis interface - - - Implement TagAnalysisScreen displaying available product tags and counts - - Add sample product display for selected tags - - Write unit tests for tag analysis display and selection - - _Requirements: 7.1, 7.2, 7.3_ - -- [ ] 10.2 Add tag recommendations - - - Implement recommendation system for optimal target tags - - Display analysis results and suggestions to users - - Write unit tests for recommendation logic and display - - _Requirements: 7.4_ - -- [ ] 11. Implement keyboard navigation and shortcuts -- [ ] 11.1 Add global keyboard handlers - - - Create keyboard event handlers for navigation and shortcuts - - Implement consistent back/exit functionality across all screens - - Write unit tests for keyboard navigation and event handling - - _Requirements: 9.1, 9.3, 9.4_ - -- [ ] 11.2 Create help system - - - Implement help overlay displaying available shortcuts and navigation - - Add context-sensitive help for different screens - - Write unit tests for help system functionality - - _Requirements: 9.2, 9.5_ - -- [ ] 12. Integrate with existing services -- [ ] 12.1 Connect TUI to ShopifyService - - - Integrate TUI components with existing ShopifyService for API operations - - Ensure all API calls use existing service methods without modification - - Write integration tests for service connectivity - - _Requirements: 7.1, 12.1_ - -- [ ] 12.2 Connect TUI to ProductService and ProgressService - - - Integrate TUI with existing ProductService for product operations - - Connect ProgressService for logging and progress tracking - - Write integration tests for service integration - - _Requirements: 7.2, 12.2, 12.3_ - -- [ ] 12.3 Maintain CLI compatibility - - - Ensure TUI implementation doesn't break existing CLI functionality - - Verify that both interfaces can coexist and use same configuration - - Write integration tests for CLI/TUI compatibility - - _Requirements: 12.3, 12.4_ - -- [ ] 13. Implement responsive layout and terminal handling -- [ ] 13.1 Add terminal resize handling - - - Implement automatic layout adjustment for terminal resize events - - Add minimum size requirements and appropriate messaging - - Write unit tests for resize handling and layout adaptation - - _Requirements: 10.1, 10.2, 10.5_ - -- [ ] 13.2 Optimize for different screen sizes - - - Implement responsive design for small and large terminal windows - - Add scrolling support where needed for content overflow - - Write unit tests for different screen size scenarios - - _Requirements: 10.2, 10.3, 10.4_ - -- [ ] 14. Add accessibility and modern terminal features -- [ ] 14.1 Implement accessibility features - - - Add screen reader support and high contrast mode compatibility - - Implement clear focus indicators for keyboard navigation - - Write tests for accessibility features - - _Requirements: 8.1, 8.2, 8.3_ - -- [ ] 14.2 Add modern terminal feature support - - - Implement true color support and enhanced Unicode character usage - - Add mouse interaction support where appropriate - - Write tests for modern terminal feature detection and usage - - _Requirements: 12.1, 12.2, 12.3_ - -- [ ] 15. Performance optimization and testing -- [ ] 15.1 Optimize rendering performance - - - Implement React.memo for expensive components and virtual scrolling for large lists - - Add debouncing for rapid state updates and minimize unnecessary re-renders - - Write performance tests and benchmarks - - _Requirements: 4.1, 4.3, 4.4_ - -- [ ] 15.2 Add memory management - - - Implement proper cleanup for event listeners and timers - - Add memory usage monitoring for long-running operations - - Write tests for memory leak detection and cleanup - - _Requirements: 4.2, 4.5_ - -- [ ] 16. Cross-platform testing and Windows optimization -- [ ] 16.1 Test Windows compatibility - - - Run comprehensive tests on Windows Terminal, Command Prompt, and PowerShell - - Verify Unicode character rendering and color support on Windows - - Write Windows-specific integration tests - - _Requirements: 1.1, 1.2, 1.3, 1.4_ - -- [ ] 16.2 Optimize for Windows performance - - - Implement Windows-specific optimizations for terminal rendering - - Add Windows-specific keyboard event handling - - Write performance tests specifically for Windows environments - - _Requirements: 1.5, 4.4_ - -- [ ] 17. Documentation and migration cleanup -- [ ] 17.1 Update documentation - - - Update README files with new TUI library information and setup instructions - - Document new component architecture and development patterns - - Create troubleshooting guide for Windows-specific issues - - _Requirements: 9.1, 9.2, 9.4_ - -- [ ] 17.2 Clean up legacy Blessed code - - Remove all Blessed dependencies and related code files - - Clean up any remaining references to Blessed in documentation - - Verify complete migration through final testing - - _Requirements: 2.5, 11.5_ - - - -.kiro\steering\product.md: - -# Product Overview - -## Shopify Price Updater - -A Node.js command-line tool for bulk updating Shopify product prices based on product tags using Shopify's GraphQL Admin API. - -### Core Features - -- **Tag-based filtering**: Updates prices only for products with specific tags -- **Dual operation modes**: - - `update`: Adjusts prices by percentage and sets compare-at prices - - `rollback`: Reverts prices using compare-at price values -- **Batch processing**: Handles large inventories with automatic pagination -- **Error resilience**: Continues processing with comprehensive error handling -- **Rate limit handling**: Automatic retry logic with exponential backoff -- **Progress tracking**: Detailed logging to console and Progress.md file - -### Target Users - -- Shopify store owners managing promotional pricing -- E-commerce teams running seasonal sales campaigns -- Developers automating price management workflows - -### Key Value Propositions - -- Reduces manual price update effort from hours to minutes -- Provides rollback capability for promotional campaigns -- Maintains data integrity with validation and error handling -- Offers detailed audit trails through comprehensive logging - - - -.kiro\steering\structure.md: - -# Project Structure - -## Directory Organization - -``` -shopify-price-updater/ -├── src/ # Main application source code -│ ├── config/ # Configuration management -│ │ └── environment.js # Environment variable validation & loading -│ ├── services/ # Business logic services -│ │ ├── shopify.js # Shopify API client & GraphQL operations -│ │ ├── product.js # Product operations & price calculations -│ │ └── progress.js # Progress tracking & logging service -│ ├── utils/ # Utility functions -│ │ ├── price.js # Price calculation & validation utilities -│ │ └── logger.js # Logging utilities & formatters -│ └── index.js # Main application entry point -├── tests/ # Test suites -│ ├── config/ # Configuration tests -│ ├── services/ # Service layer tests -│ ├── utils/ # Utility function tests -│ ├── integration/ # Integration tests -│ └── index.test.js # Main application tests -├── backend/ # Separate backend component (minimal) -│ └── .env # Backend-specific environment config -├── .env # Main environment configuration -├── .env.example # Environment template -├── Progress.md # Generated operation logs -├── debug-tags.js # Standalone tag analysis script -└── test-*.js # Individual test scripts -``` - -## Code Organization Patterns - -### Service Layer Structure - -- **ShopifyService**: API client, authentication, retry logic -- **ProductService**: Product fetching, validation, price updates -- **ProgressService**: Logging, progress tracking, file operations - -### Configuration Management - -- Centralized in `src/config/environment.js` -- Validation at startup with clear error messages -- Environment-specific defaults and overrides - -### Error Handling Strategy - -- Service-level error categorization (rate limits, network, validation) -- Comprehensive retry logic with exponential backoff -- Detailed error logging with context preservation - -### Testing Structure - -- Unit tests for utilities and individual services -- Integration tests for complete workflows -- Mock-based testing for external API dependencies -- Separate test files for different operation modes - -## File Naming Conventions - -- **Services**: `[domain].js` (e.g., `shopify.js`, `product.js`) -- **Utilities**: `[function].js` (e.g., `price.js`, `logger.js`) -- **Tests**: `[module].test.js` or `test-[feature].js` -- **Configuration**: Descriptive names (e.g., `environment.js`) - -## Entry Points - -- **Main Application**: `src/index.js` - Complete price update workflow -- **Debug Tools**: `debug-tags.js` - Tag analysis and troubleshooting -- **Test Scripts**: `test-*.js` - Individual feature testing - - - -.kiro\steering\tech.md: - -# Technology Stack - -## Runtime & Dependencies - -- **Node.js**: >=16.0.0 (specified in package.json engines) -- **Core Dependencies**: - - `@shopify/shopify-api`: ^7.7.0 - Shopify GraphQL API client - - `dotenv`: ^16.3.1 - Environment variable management - - `node-fetch`: ^3.3.2 - HTTP client for API requests -- **Development Dependencies**: - - `jest`: ^29.7.0 - Testing framework - -## Architecture Patterns - -- **Service Layer Architecture**: Separation of concerns with dedicated services -- **Configuration Management**: Centralized environment-based configuration -- **Error Handling**: Comprehensive retry logic with exponential backoff -- **Logging Strategy**: Dual output (console + Progress.md file) - -## API Integration - -- **Shopify GraphQL Admin API**: Version 2024-01 -- **Authentication**: Private App access tokens -- **Rate Limiting**: Built-in handling with automatic retries -- **Bulk Operations**: Uses `productVariantsBulkUpdate` mutation - -## Common Commands - -### Development - -```bash -npm install # Install dependencies -npm start # Run with default settings -npm run update # Explicit update mode -npm run rollback # Rollback mode -npm run debug-tags # Debug tag analysis -npm test # Run test suite -``` - -### Environment Setup - -```bash -copy .env.example .env # Create environment file (Windows) -cp .env.example .env # Create environment file (Unix) -``` - -### Testing & Debugging - -```bash -npm run debug-tags # Analyze store tags before running -npm test # Run Jest test suite -``` - -## Environment Variables - -Required variables (see .env.example): - -- `SHOPIFY_SHOP_DOMAIN`: Store domain -- `SHOPIFY_ACCESS_TOKEN`: Admin API token -- `TARGET_TAG`: Product tag filter -- `PRICE_ADJUSTMENT_PERCENTAGE`: Adjustment percentage -- `OPERATION_MODE`: "update" or "rollback" - - - -backend\.env: - -# Shopify App Configuration -SHOPIFY_API_KEY=your_api_key_here -SHOPIFY_API_SECRET=your_api_secret_here -SHOPIFY_SCOPES=write_products,read_products -SHOPIFY_APP_URL=http://localhost:3000 -HOST=localhost:3000 - -# Database Configuration -DATABASE_URL=sqlite:./database.sqlite - -# Server Configuration -PORT=3000 -NODE_ENV=development - -# Session Configuration -SESSION_SECRET=your_session_secret_here - -# Frontend Configuration -FRONTEND_URL=http://localhost:5173 - - -docs\enhanced-signal-handling.md: - -# Enhanced Signal Handling for Scheduled Operations - -## Overview - -The enhanced signal handling system provides intelligent cancellation support for scheduled price update operations. It distinguishes between different phases of execution and responds appropriately to interrupt signals (SIGINT/SIGTERM). - -## Features - -### 1. Phase-Aware Cancellation - -The system recognizes three distinct phases: - -- **Scheduling Wait Period**: Cancellation is allowed and encouraged -- **Price Update Operations**: Cancellation is prevented to avoid data corruption -- **Normal Operations**: Standard graceful shutdown behavior - -### 2. Clear User Feedback - -When users press Ctrl+C during different phases, they receive clear, contextual messages: - -#### During Scheduling Wait Period - -``` -🛑 Received SIGINT during scheduled wait period. -📋 Cancelling scheduled operation... -✅ Scheduled operation cancelled successfully. No price updates were performed. -``` - -#### During Price Update Operations - -``` -⚠️ Received SIGINT during active price update operations. -🔒 Cannot cancel while price updates are in progress to prevent data corruption. -⏳ Please wait for current operations to complete... -💡 Tip: You can cancel during the countdown period before operations begin. -``` - -### 3. State Coordination - -The system uses state management to coordinate between the main application and the ScheduleService: - -- `schedulingActive`: Indicates if the system is in the scheduling wait period -- `operationInProgress`: Indicates if price update operations are running - -## Implementation Details - -### Main Application Signal Handlers - -Located in `src/index.js`, the enhanced signal handlers: - -1. Check the current phase (scheduling vs. operations) -2. Provide appropriate user feedback -3. Coordinate with the ScheduleService for cleanup -4. Prevent interruption during critical operations - -### ScheduleService Integration - -The `ScheduleService` (`src/services/schedule.js`) has been updated to: - -1. Remove its own signal handling (delegated to main application) -2. Support external cancellation through the `cleanup()` method -3. Provide proper resource cleanup and state management - -### State Management Functions - -The main application provides state management functions to the ShopifyPriceUpdater instance: - -- `setSchedulingActive(boolean)`: Updates scheduling phase state -- `setOperationInProgress(boolean)`: Updates operation phase state - -## Usage Examples - -### Scheduled Operation with Cancellation - -```bash -# Set scheduled execution time -export SCHEDULED_EXECUTION_TIME="2025-08-07T15:30:00" - -# Run the application -npm start - -# During countdown, press Ctrl+C to cancel -# Result: Clean cancellation with confirmation message -``` - -### Scheduled Operation During Updates - -```bash -# Same setup, but let the countdown complete -# Once price updates begin, press Ctrl+C -# Result: Cancellation is prevented with explanation -``` - -## Testing - -The enhanced signal handling is thoroughly tested in: - -- `tests/services/schedule-signal-handling.test.js`: Unit tests for all requirements -- Tests cover all three requirements: - - 3.1: Cancellation during wait period - - 3.2: Clear cancellation confirmation messages - - 3.3: No interruption once operations begin - -## Requirements Compliance - -### Requirement 3.1: Cancellation Support - -✅ **IMPLEMENTED**: System responds to SIGINT and SIGTERM during wait period - -### Requirement 3.2: Clear Confirmation Messages - -✅ **IMPLEMENTED**: Contextual messages inform users about cancellation status - -### Requirement 3.3: Operation Protection - -✅ **IMPLEMENTED**: Price updates cannot be interrupted to prevent data corruption - -## Benefits - -1. **Data Integrity**: Prevents partial updates that could corrupt pricing data -2. **User Experience**: Clear feedback about what's happening and what's possible -3. **Flexibility**: Users can cancel during planning phase but not during execution -4. **Reliability**: Proper resource cleanup and state management - - - -src\config\environment.js: - -const dotenv = require("dotenv"); - -// Load environment variables from .env file -dotenv.config(); - -/** - * Validates and loads environment configuration - * @returns {Object} Configuration object with validated environment variables - * @throws {Error} If required environment variables are missing or invalid - */ -function loadEnvironmentConfig() { - const config = { - shopDomain: process.env.SHOPIFY_SHOP_DOMAIN, - accessToken: process.env.SHOPIFY_ACCESS_TOKEN, - targetTag: process.env.TARGET_TAG, - priceAdjustmentPercentage: process.env.PRICE_ADJUSTMENT_PERCENTAGE, - operationMode: process.env.OPERATION_MODE || "update", // Default to "update" for backward compatibility - scheduledExecutionTime: process.env.SCHEDULED_EXECUTION_TIME || null, // Optional scheduling parameter - }; - - // Validate required environment variables - const requiredVars = [ - { - key: "SHOPIFY_SHOP_DOMAIN", - value: config.shopDomain, - name: "Shopify shop domain", - }, - { - key: "SHOPIFY_ACCESS_TOKEN", - value: config.accessToken, - name: "Shopify access token", - }, - { key: "TARGET_TAG", value: config.targetTag, name: "Target product tag" }, - { - key: "PRICE_ADJUSTMENT_PERCENTAGE", - value: config.priceAdjustmentPercentage, - name: "Price adjustment percentage", - }, - ]; - - const missingVars = []; - - for (const variable of requiredVars) { - if (!variable.value || variable.value.trim() === "") { - missingVars.push(variable.key); - } - } - - if (missingVars.length > 0) { - const errorMessage = - `Missing required environment variables: ${missingVars.join(", ")}\n` + - "Please check your .env file and ensure all required variables are set.\n" + - "See .env.example for reference."; - throw new Error(errorMessage); - } - - // Validate and convert price adjustment percentage - const percentage = parseFloat(config.priceAdjustmentPercentage); - if (isNaN(percentage)) { - throw new Error( - `Invalid PRICE_ADJUSTMENT_PERCENTAGE: "${config.priceAdjustmentPercentage}". Must be a valid number.` - ); - } - - // Validate shop domain format - if ( - !config.shopDomain.includes(".myshopify.com") && - !config.shopDomain.includes(".") - ) { - throw new Error( - `Invalid SHOPIFY_SHOP_DOMAIN: "${config.shopDomain}". Must be a valid Shopify domain (e.g., your-shop.myshopify.com)` - ); - } - - // Validate access token format (basic check) - if (config.accessToken.length < 10) { - throw new Error( - "Invalid SHOPIFY_ACCESS_TOKEN: Token appears to be too short. Please verify your access token." - ); - } - - // Validate target tag is not empty after trimming - const trimmedTag = config.targetTag.trim(); - if (trimmedTag === "") { - throw new Error( - "Invalid TARGET_TAG: Tag cannot be empty or contain only whitespace." - ); - } - - // Validate operation mode - const validOperationModes = ["update", "rollback"]; - if (!validOperationModes.includes(config.operationMode)) { - throw new Error( - `Invalid OPERATION_MODE: "${config.operationMode}". Must be either "update" or "rollback".` - ); - } - - // Validate scheduled execution time if provided using enhanced ScheduleService validation - let scheduledTime = null; - let isScheduled = false; - - if (config.scheduledExecutionTime) { - // Use ScheduleService for comprehensive validation and error handling - const ScheduleService = require("../services/schedule"); - const Logger = require("../utils/logger"); - - // Create a temporary schedule service instance for validation - const tempLogger = new Logger(); - const scheduleService = new ScheduleService(tempLogger); - - try { - // Use the enhanced validation from ScheduleService - scheduledTime = scheduleService.parseScheduledTime( - config.scheduledExecutionTime - ); - isScheduled = true; - } catch (error) { - // Re-throw with environment configuration context - throw new Error( - `SCHEDULED_EXECUTION_TIME validation failed:\n${error.message}` - ); - } - } - - // Return validated configuration - return { - shopDomain: config.shopDomain.trim(), - accessToken: config.accessToken.trim(), - targetTag: trimmedTag, - priceAdjustmentPercentage: percentage, - operationMode: config.operationMode, - scheduledExecutionTime: scheduledTime, - isScheduled: isScheduled, - }; -} - -/** - * Get validated environment configuration - * Caches the configuration after first load - */ -let cachedConfig = null; - -function getConfig() { - if (!cachedConfig) { - try { - cachedConfig = loadEnvironmentConfig(); - } catch (error) { - console.error("Environment Configuration Error:"); - console.error(error.message); - process.exit(1); - } - } - return cachedConfig; -} - -module.exports = { - getConfig, - loadEnvironmentConfig, // Export for testing purposes -}; - - - -src\services\product.js: - -const ShopifyService = require("./shopify"); -const { - calculateNewPrice, - preparePriceUpdate, - validateRollbackEligibility, - prepareRollbackUpdate, -} = require("../utils/price"); -const Logger = require("../utils/logger"); - -/** - * Product service for querying and updating Shopify products - * Handles product fetching by tag and price updates - */ -class ProductService { - constructor() { - this.shopifyService = new ShopifyService(); - this.logger = new Logger(); - this.pageSize = 50; // Shopify recommends max 250, but 50 is safer for rate limits - this.batchSize = 10; // Process variants in batches to manage rate limits - } - - /** - * GraphQL query to fetch products by tag with pagination - */ - getProductsByTagQuery() { - return ` - query getProductsByTag($query: String!, $first: Int!, $after: String) { - products(first: $first, after: $after, query: $query) { - edges { - node { - id - title - tags - variants(first: 100) { - edges { - node { - id - price - compareAtPrice - title - } - } - } - } - cursor - } - pageInfo { - hasNextPage - endCursor - } - } - } - `; - } - - /** - * GraphQL query to fetch all products (for debugging tag issues) - */ - getAllProductsQuery() { - return ` - query getAllProducts($first: Int!, $after: String) { - products(first: $first, after: $after) { - edges { - node { - id - title - tags - } - cursor - } - pageInfo { - hasNextPage - endCursor - } - } - } - `; - } - - /** - * GraphQL mutation to update product variant price and Compare At price - */ - getProductVariantUpdateMutation() { - return ` - mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { - productVariantsBulkUpdate(productId: $productId, variants: $variants) { - productVariants { - id - price - compareAtPrice - } - userErrors { - field - message - } - } - } - `; - } - - /** - * Fetch all products with the specified tag using cursor-based pagination - * @param {string} tag - Tag to filter products by - * @returns {Promise} Array of products with their variants - */ - async fetchProductsByTag(tag) { - await this.logger.info(`Starting to fetch products with tag: ${tag}`); - - const allProducts = []; - let hasNextPage = true; - let cursor = null; - let pageCount = 0; - - try { - while (hasNextPage) { - pageCount++; - await this.logger.info(`Fetching page ${pageCount} of products...`); - - const queryString = tag.startsWith("tag:") ? tag : `tag:${tag}`; - const variables = { - query: queryString, // Shopify query format for tags - first: this.pageSize, - after: cursor, - }; - - await this.logger.info(`Using GraphQL query string: "${queryString}"`); - - const response = await this.shopifyService.executeWithRetry( - () => - this.shopifyService.executeQuery( - this.getProductsByTagQuery(), - variables - ), - this.logger - ); - - if (!response.products) { - throw new Error("Invalid response structure: missing products field"); - } - - const { edges, pageInfo } = response.products; - - // Process products from this page - const pageProducts = edges.map((edge) => ({ - id: edge.node.id, - title: edge.node.title, - tags: edge.node.tags, - variants: edge.node.variants.edges.map((variantEdge) => ({ - id: variantEdge.node.id, - price: parseFloat(variantEdge.node.price), - compareAtPrice: variantEdge.node.compareAtPrice - ? parseFloat(variantEdge.node.compareAtPrice) - : null, - title: variantEdge.node.title, - })), - })); - - allProducts.push(...pageProducts); - await this.logger.info( - `Found ${pageProducts.length} products on page ${pageCount}` - ); - - // Update pagination info - hasNextPage = pageInfo.hasNextPage; - cursor = pageInfo.endCursor; - - // Log progress for large datasets - if (allProducts.length > 0 && allProducts.length % 100 === 0) { - await this.logger.info( - `Total products fetched so far: ${allProducts.length}` - ); - } - } - - await this.logger.info( - `Successfully fetched ${allProducts.length} products with tag: ${tag}` - ); - - // Log variant count for additional context - const totalVariants = allProducts.reduce( - (sum, product) => sum + product.variants.length, - 0 - ); - await this.logger.info(`Total product variants found: ${totalVariants}`); - - return allProducts; - } catch (error) { - await this.logger.error( - `Failed to fetch products with tag ${tag}: ${error.message}` - ); - throw new Error(`Product fetching failed: ${error.message}`); - } - } - - /** - * Validate that products have the required data for price updates - * @param {Array} products - Array of products to validate - * @returns {Promise} Array of valid products - */ - async validateProducts(products) { - const validProducts = []; - let skippedCount = 0; - - for (const product of products) { - // Check if product has variants - if (!product.variants || product.variants.length === 0) { - await this.logger.warning( - `Skipping product "${product.title}" - no variants found` - ); - skippedCount++; - continue; - } - - // Check if variants have valid price data - const validVariants = []; - for (const variant of product.variants) { - if (typeof variant.price !== "number" || isNaN(variant.price)) { - await this.logger.warning( - `Skipping variant "${variant.title}" in product "${product.title}" - invalid price: ${variant.price}` - ); - continue; - } - if (variant.price < 0) { - await this.logger.warning( - `Skipping variant "${variant.title}" in product "${product.title}" - negative price: ${variant.price}` - ); - continue; - } - validVariants.push(variant); - } - - if (validVariants.length === 0) { - await this.logger.warning( - `Skipping product "${product.title}" - no variants with valid prices` - ); - skippedCount++; - continue; - } - - // Add product with only valid variants - validProducts.push({ - ...product, - variants: validVariants, - }); - } - - if (skippedCount > 0) { - await this.logger.warning( - `Skipped ${skippedCount} products due to invalid data` - ); - } - - await this.logger.info( - `Validated ${validProducts.length} products for price updates` - ); - return validProducts; - } - - /** - * Validate that products have the required data for rollback operations - * Filters products to only include those with variants that have valid compare-at prices - * @param {Array} products - Array of products to validate for rollback - * @returns {Promise} Array of products eligible for rollback - */ - async validateProductsForRollback(products) { - const eligibleProducts = []; - let skippedProductCount = 0; - let totalVariantsProcessed = 0; - let eligibleVariantsCount = 0; - - await this.logger.info( - `Starting rollback validation for ${products.length} products` - ); - - for (const product of products) { - // Check if product has variants - if (!product.variants || product.variants.length === 0) { - await this.logger.warning( - `Skipping product "${product.title}" for rollback - no variants found` - ); - skippedProductCount++; - continue; - } - - // Check each variant for rollback eligibility - const eligibleVariants = []; - for (const variant of product.variants) { - totalVariantsProcessed++; - - const eligibilityResult = validateRollbackEligibility(variant); - - if (eligibilityResult.isEligible) { - eligibleVariants.push(variant); - eligibleVariantsCount++; - } else { - await this.logger.warning( - `Skipping variant "${variant.title}" in product "${product.title}" for rollback - ${eligibilityResult.reason}` - ); - } - } - - // Only include products that have at least one eligible variant - if (eligibleVariants.length === 0) { - await this.logger.warning( - `Skipping product "${product.title}" for rollback - no variants with valid compare-at prices` - ); - skippedProductCount++; - continue; - } - - // Add product with only eligible variants - eligibleProducts.push({ - ...product, - variants: eligibleVariants, - }); - } - - // Log validation summary - if (skippedProductCount > 0) { - await this.logger.warning( - `Skipped ${skippedProductCount} products during rollback validation` - ); - } - - await this.logger.info( - `Rollback validation completed: ${eligibleProducts.length} products eligible (${eligibleVariantsCount}/${totalVariantsProcessed} variants eligible)` - ); - - return eligibleProducts; - } - - /** - * Update a single product variant price for rollback operation - * Sets the main price to the compare-at price and removes the compare-at price - * @param {Object} variant - Variant to rollback - * @param {string} variant.id - Variant ID - * @param {number} variant.price - Current price - * @param {number} variant.compareAtPrice - Compare-at price to use as new price - * @param {string} productId - Product ID that contains this variant - * @returns {Promise} Rollback result - */ - async rollbackVariantPrice(variant, productId) { - try { - // Validate rollback eligibility before attempting operation (Requirement 4.1) - const eligibilityResult = validateRollbackEligibility(variant); - if (!eligibilityResult.isEligible) { - return { - success: false, - error: `Rollback not eligible: ${eligibilityResult.reason}`, - rollbackDetails: { - oldPrice: variant.price, - compareAtPrice: variant.compareAtPrice, - newPrice: null, - }, - errorType: "validation", - retryable: false, - }; - } - - // Prepare rollback update using utility function - const rollbackUpdate = prepareRollbackUpdate(variant); - - const variables = { - productId: productId, - variants: [ - { - id: variant.id, - price: rollbackUpdate.newPrice.toString(), // Shopify expects price as string - compareAtPrice: rollbackUpdate.compareAtPrice, // null to remove compare-at price - }, - ], - }; - - // Use existing retry logic for rollback API operations (Requirement 4.2) - const response = await this.shopifyService.executeWithRetry( - () => - this.shopifyService.executeMutation( - this.getProductVariantUpdateMutation(), - variables - ), - this.logger - ); - - // Check for user errors in the response - if ( - response.productVariantsBulkUpdate.userErrors && - response.productVariantsBulkUpdate.userErrors.length > 0 - ) { - const errors = response.productVariantsBulkUpdate.userErrors - .map((error) => `${error.field}: ${error.message}`) - .join(", "); - - // Categorize Shopify API errors for better error analysis (Requirement 4.5) - const errorType = this.categorizeShopifyError(errors); - - throw new Error(`Shopify API errors: ${errors}`); - } - - return { - success: true, - updatedVariant: response.productVariantsBulkUpdate.productVariants[0], - rollbackDetails: { - oldPrice: variant.price, - compareAtPrice: variant.compareAtPrice, - newPrice: rollbackUpdate.newPrice, - }, - }; - } catch (error) { - // Enhanced error handling with categorization (Requirements 4.3, 4.4, 4.5) - const errorInfo = this.analyzeRollbackError(error, variant); - - return { - success: false, - error: error.message, - rollbackDetails: { - oldPrice: variant.price, - compareAtPrice: variant.compareAtPrice, - newPrice: null, - }, - errorType: errorInfo.type, - retryable: errorInfo.retryable, - errorHistory: error.errorHistory || null, - }; - } - } - - /** - * Process a single product for rollback operations - * @param {Object} product - Product to process for rollback - * @param {Object} results - Results object to update - * @returns {Promise} - */ - async processProductForRollback(product, results) { - for (const variant of product.variants) { - results.totalVariants++; - - try { - // Perform rollback operation on the variant with enhanced error handling - const rollbackResult = await this.rollbackVariantPrice( - variant, - product.id - ); - - if (rollbackResult.success) { - results.successfulRollbacks++; - - // Log successful rollback using rollback-specific logging method - await this.logger.logRollbackUpdate({ - productId: product.id, - productTitle: product.title, - variantId: variant.id, - oldPrice: rollbackResult.rollbackDetails.oldPrice, - newPrice: rollbackResult.rollbackDetails.newPrice, - compareAtPrice: rollbackResult.rollbackDetails.compareAtPrice, - }); - } else { - // Handle different types of rollback failures (Requirements 4.1, 4.3, 4.4) - if (rollbackResult.errorType === "validation") { - // Skip variants without compare-at prices gracefully (Requirement 4.1) - results.skippedVariants++; - - await this.logger.warning( - `Skipped variant "${variant.title || variant.id}" in product "${ - product.title - }": ${rollbackResult.error}` - ); - } else { - // Handle API and other errors (Requirements 4.2, 4.3, 4.4) - results.failedRollbacks++; - - // Enhanced error entry with additional context (Requirement 4.5) - const errorEntry = { - productId: product.id, - productTitle: product.title, - variantId: variant.id, - errorMessage: rollbackResult.error, - errorType: rollbackResult.errorType, - retryable: rollbackResult.retryable, - errorHistory: rollbackResult.errorHistory, - }; - - results.errors.push(errorEntry); - - // Log rollback-specific error with enhanced details - await this.logger.error( - `Rollback failed for variant "${ - variant.title || variant.id - }" in product "${product.title}": ${rollbackResult.error}` - ); - - // Log to progress file as well - await this.logger.logProductError(errorEntry); - } - } - } catch (error) { - // Handle unexpected errors that bypass the rollbackVariantPrice error handling (Requirement 4.4) - results.failedRollbacks++; - - // Analyze unexpected error for better categorization - const errorInfo = this.analyzeRollbackError(error, variant); - - const errorEntry = { - productId: product.id, - productTitle: product.title, - variantId: variant.id, - errorMessage: `Unexpected rollback error: ${error.message}`, - errorType: errorInfo.type, - retryable: errorInfo.retryable, - errorHistory: error.errorHistory || null, - }; - - results.errors.push(errorEntry); - - await this.logger.error( - `Unexpected rollback error for variant "${ - variant.title || variant.id - }" in product "${product.title}": ${error.message}` - ); - - await this.logger.logProductError(errorEntry); - } - } - } - - /** - * Rollback prices for all variants in a batch of products - * Sets main prices to compare-at prices and removes compare-at prices - * @param {Array} products - Array of products to rollback - * @returns {Promise} Batch rollback results - */ - async rollbackProductPrices(products) { - await this.logger.info( - `Starting price rollback for ${products.length} products` - ); - - const results = { - totalProducts: products.length, - totalVariants: 0, - eligibleVariants: products.reduce( - (sum, product) => sum + product.variants.length, - 0 - ), - successfulRollbacks: 0, - failedRollbacks: 0, - skippedVariants: 0, - errors: [], - }; - - let processedProducts = 0; - let consecutiveErrors = 0; - const maxConsecutiveErrors = 5; // Stop processing if too many consecutive errors - - // Process products in batches to manage rate limits with enhanced error handling - for (let i = 0; i < products.length; i += this.batchSize) { - const batch = products.slice(i, i + this.batchSize); - const batchNumber = Math.floor(i / this.batchSize) + 1; - const totalBatches = Math.ceil(products.length / this.batchSize); - - await this.logger.info( - `Processing rollback batch ${batchNumber} of ${totalBatches} (${batch.length} products)` - ); - - let batchErrors = 0; - const batchStartTime = Date.now(); - - // Process each product in the batch with error recovery (Requirements 4.3, 4.4) - for (const product of batch) { - try { - const variantsBefore = results.totalVariants; - await this.processProductForRollback(product, results); - - // Check if this product had any successful operations - const variantsProcessed = results.totalVariants - variantsBefore; - const productErrors = results.errors.filter( - (e) => e.productId === product.id - ).length; - - if (productErrors === 0) { - consecutiveErrors = 0; // Reset consecutive error counter on success - } else if (productErrors === variantsProcessed) { - // All variants in this product failed - consecutiveErrors++; - batchErrors++; - } - - processedProducts++; - - // Log progress for large batches - if (processedProducts % 10 === 0) { - await this.logger.info( - `Progress: ${processedProducts}/${products.length} products processed` - ); - } - } catch (error) { - // Handle product-level errors that bypass processProductForRollback (Requirement 4.4) - consecutiveErrors++; - batchErrors++; - - await this.logger.error( - `Failed to process product "${product.title}" (${product.id}): ${error.message}` - ); - - // Add product-level error to results - const errorEntry = { - productId: product.id, - productTitle: product.title, - variantId: "N/A", - errorMessage: `Product processing failed: ${error.message}`, - errorType: "product_processing_error", - retryable: false, - }; - - results.errors.push(errorEntry); - await this.logger.logProductError(errorEntry); - } - - // Check for too many consecutive errors (Requirement 4.4) - if (consecutiveErrors >= maxConsecutiveErrors) { - await this.logger.error( - `Stopping rollback operation due to ${maxConsecutiveErrors} consecutive errors. This may indicate a systemic issue.` - ); - - // Add summary error for remaining products - const remainingProducts = products.length - processedProducts; - if (remainingProducts > 0) { - const systemErrorEntry = { - productId: "SYSTEM", - productTitle: `${remainingProducts} remaining products`, - variantId: "N/A", - errorMessage: `Processing stopped due to consecutive errors (${maxConsecutiveErrors} in a row)`, - errorType: "system_error", - retryable: true, - }; - results.errors.push(systemErrorEntry); - } - - break; // Exit product loop - } - } - - // Exit batch loop if we hit consecutive error limit - if (consecutiveErrors >= maxConsecutiveErrors) { - break; - } - - // Log batch completion with error summary - const batchDuration = Math.round((Date.now() - batchStartTime) / 1000); - if (batchErrors > 0) { - await this.logger.warning( - `Batch ${batchNumber} completed with ${batchErrors} product errors in ${batchDuration}s` - ); - } else { - await this.logger.info( - `Batch ${batchNumber} completed successfully in ${batchDuration}s` - ); - } - - // Add adaptive delay between batches based on error rate (Requirement 4.2) - if (i + this.batchSize < products.length) { - let delay = 500; // Base delay - - // Increase delay if we're seeing errors (rate limiting or server issues) - if (batchErrors > 0) { - delay = Math.min(delay * (1 + batchErrors), 5000); // Cap at 5 seconds - await this.logger.info( - `Increasing delay to ${delay}ms due to batch errors` - ); - } - - await this.delay(delay); - } - } - - // Enhanced completion logging with error analysis (Requirement 4.5) - const successRate = - results.eligibleVariants > 0 - ? ( - (results.successfulRollbacks / results.eligibleVariants) * - 100 - ).toFixed(1) - : 0; - - await this.logger.info( - `Price rollback completed. Success: ${results.successfulRollbacks}, Failed: ${results.failedRollbacks}, Skipped: ${results.skippedVariants}, Success Rate: ${successRate}%` - ); - - // Log error summary if there were failures - if (results.errors.length > 0) { - const errorsByType = {}; - results.errors.forEach((error) => { - const type = error.errorType || "unknown"; - errorsByType[type] = (errorsByType[type] || 0) + 1; - }); - - await this.logger.warning( - `Error breakdown: ${Object.entries(errorsByType) - .map(([type, count]) => `${type}: ${count}`) - .join(", ")}` - ); - } - - return results; - } - - /** - * Get summary statistics for fetched products - * @param {Array} products - Array of products - * @returns {Object} Summary statistics - */ - getProductSummary(products) { - const totalProducts = products.length; - const totalVariants = products.reduce( - (sum, product) => sum + product.variants.length, - 0 - ); - - const priceRanges = products.reduce( - (ranges, product) => { - product.variants.forEach((variant) => { - if (variant.price < ranges.min) ranges.min = variant.price; - if (variant.price > ranges.max) ranges.max = variant.price; - }); - return ranges; - }, - { min: Infinity, max: -Infinity } - ); - - // Handle case where no products were found - if (totalProducts === 0) { - priceRanges.min = 0; - priceRanges.max = 0; - } - - return { - totalProducts, - totalVariants, - priceRange: { - min: priceRanges.min === Infinity ? 0 : priceRanges.min, - max: priceRanges.max === -Infinity ? 0 : priceRanges.max, - }, - }; - } - - /** - * Update a single product variant price and Compare At price - * @param {Object} variant - Variant to update - * @param {string} variant.id - Variant ID - * @param {number} variant.price - Current price - * @param {string} productId - Product ID that contains this variant - * @param {number} newPrice - New price to set - * @param {number} compareAtPrice - Compare At price to set (original price) - * @returns {Promise} Update result - */ - async updateVariantPrice(variant, productId, newPrice, compareAtPrice) { - try { - const variables = { - productId: productId, - variants: [ - { - id: variant.id, - price: newPrice.toString(), // Shopify expects price as string - compareAtPrice: compareAtPrice.toString(), // Shopify expects compareAtPrice as string - }, - ], - }; - - const response = await this.shopifyService.executeWithRetry( - () => - this.shopifyService.executeMutation( - this.getProductVariantUpdateMutation(), - variables - ), - this.logger - ); - - // Check for user errors in the response - if ( - response.productVariantsBulkUpdate.userErrors && - response.productVariantsBulkUpdate.userErrors.length > 0 - ) { - const errors = response.productVariantsBulkUpdate.userErrors - .map((error) => `${error.field}: ${error.message}`) - .join(", "); - throw new Error(`Shopify API errors: ${errors}`); - } - - return { - success: true, - updatedVariant: response.productVariantsBulkUpdate.productVariants[0], - }; - } catch (error) { - return { - success: false, - error: error.message, - }; - } - } - - /** - * Update prices for all variants in a batch of products - * @param {Array} products - Array of products to update - * @param {number} priceAdjustmentPercentage - Percentage to adjust prices - * @returns {Promise} Batch update results - */ - async updateProductPrices(products, priceAdjustmentPercentage) { - await this.logger.info( - `Starting price updates for ${products.length} products` - ); - - const results = { - totalProducts: products.length, - totalVariants: 0, - successfulUpdates: 0, - failedUpdates: 0, - errors: [], - }; - - // Process products in batches to manage rate limits - for (let i = 0; i < products.length; i += this.batchSize) { - const batch = products.slice(i, i + this.batchSize); - await this.logger.info( - `Processing batch ${Math.floor(i / this.batchSize) + 1} of ${Math.ceil( - products.length / this.batchSize - )}` - ); - - await this.processBatch(batch, priceAdjustmentPercentage, results); - - // Add a small delay between batches to be respectful of rate limits - if (i + this.batchSize < products.length) { - await this.delay(500); // 500ms delay between batches - } - } - - await this.logger.info( - `Price update completed. Success: ${results.successfulUpdates}, Failed: ${results.failedUpdates}` - ); - return results; - } - - /** - * Process a batch of products for price updates - * @param {Array} batch - Batch of products to process - * @param {number} priceAdjustmentPercentage - Percentage to adjust prices - * @param {Object} results - Results object to update - * @returns {Promise} - */ - async processBatch(batch, priceAdjustmentPercentage, results) { - for (const product of batch) { - await this.processProduct(product, priceAdjustmentPercentage, results); - } - } - - /** - * Process a single product for price updates - * @param {Object} product - Product to process - * @param {number} priceAdjustmentPercentage - Percentage to adjust prices - * @param {Object} results - Results object to update - * @returns {Promise} - */ - async processProduct(product, priceAdjustmentPercentage, results) { - for (const variant of product.variants) { - results.totalVariants++; - - try { - // Prepare price update with Compare At price - const priceUpdate = preparePriceUpdate( - variant.price, - priceAdjustmentPercentage - ); - - // Update the variant price and Compare At price - const updateResult = await this.updateVariantPrice( - variant, - product.id, - priceUpdate.newPrice, - priceUpdate.compareAtPrice - ); - - if (updateResult.success) { - results.successfulUpdates++; - - // Log successful update with Compare At price - await this.logger.logProductUpdate({ - productId: product.id, - productTitle: product.title, - variantId: variant.id, - oldPrice: variant.price, - newPrice: priceUpdate.newPrice, - compareAtPrice: priceUpdate.compareAtPrice, - }); - } else { - results.failedUpdates++; - - // Log failed update - const errorEntry = { - productId: product.id, - productTitle: product.title, - variantId: variant.id, - errorMessage: updateResult.error, - }; - - results.errors.push(errorEntry); - await this.logger.logProductError(errorEntry); - } - } catch (error) { - results.failedUpdates++; - - // Log calculation or other errors - const errorEntry = { - productId: product.id, - productTitle: product.title, - variantId: variant.id, - errorMessage: `Price calculation failed: ${error.message}`, - }; - - results.errors.push(errorEntry); - await this.logger.logProductError(errorEntry); - } - } - } - - /** - * Debug method to fetch all products and show their tags - * @param {number} limit - Maximum number of products to fetch for debugging - * @returns {Promise} Array of products with their tags - */ - async debugFetchAllProductTags(limit = 50) { - await this.logger.info( - `Fetching up to ${limit} products to analyze tags...` - ); - - const allProducts = []; - let hasNextPage = true; - let cursor = null; - let fetchedCount = 0; - - try { - while (hasNextPage && fetchedCount < limit) { - const variables = { - first: Math.min(this.pageSize, limit - fetchedCount), - after: cursor, - }; - - const response = await this.shopifyService.executeWithRetry( - () => - this.shopifyService.executeQuery( - this.getAllProductsQuery(), - 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, - })); - - allProducts.push(...pageProducts); - fetchedCount += pageProducts.length; - - // Update pagination info - hasNextPage = pageInfo.hasNextPage && fetchedCount < limit; - cursor = pageInfo.endCursor; - } - - // Collect all unique tags - const allTags = new Set(); - allProducts.forEach((product) => { - if (product.tags && Array.isArray(product.tags)) { - product.tags.forEach((tag) => allTags.add(tag)); - } - }); - - await this.logger.info( - `Found ${allProducts.length} products with ${allTags.size} unique tags` - ); - - // Log first few products and their tags for debugging - const sampleProducts = allProducts.slice(0, 5); - for (const product of sampleProducts) { - await this.logger.info( - `Product: "${product.title}" - Tags: [${ - product.tags ? product.tags.join(", ") : "no tags" - }]` - ); - } - - // Log all unique tags found - const sortedTags = Array.from(allTags).sort(); - await this.logger.info( - `All tags found in store: [${sortedTags.join(", ")}]` - ); - - return allProducts; - } catch (error) { - await this.logger.error( - `Failed to fetch products for tag debugging: ${error.message}` - ); - throw new Error(`Debug fetch failed: ${error.message}`); - } - } - - /** - * Categorize Shopify API errors for better error analysis (Requirement 4.5) - * @param {string} errorMessage - Shopify API error message - * @returns {string} Error category - */ - categorizeShopifyError(errorMessage) { - const message = errorMessage.toLowerCase(); - - if ( - message.includes("price") && - (message.includes("invalid") || message.includes("must be")) - ) { - return "price_validation"; - } - if (message.includes("variant") && message.includes("not found")) { - return "variant_not_found"; - } - if (message.includes("product") && message.includes("not found")) { - return "product_not_found"; - } - if (message.includes("permission") || message.includes("access")) { - return "permission_denied"; - } - if (message.includes("rate limit") || message.includes("throttled")) { - return "rate_limit"; - } - - return "shopify_api_error"; - } - - /** - * Analyze rollback errors for enhanced error handling (Requirements 4.3, 4.4, 4.5) - * @param {Error} error - Error to analyze - * @param {Object} variant - Variant that caused the error - * @returns {Object} Error analysis result - */ - analyzeRollbackError(error, variant) { - const message = error.message.toLowerCase(); - - // Network and connection errors (retryable) - if ( - message.includes("network") || - message.includes("connection") || - message.includes("timeout") || - message.includes("econnreset") - ) { - return { - type: "network_error", - retryable: true, - category: "Network Issues", - }; - } - - // Rate limiting errors (retryable) - if ( - message.includes("rate limit") || - message.includes("429") || - message.includes("throttled") - ) { - return { - type: "rate_limit", - retryable: true, - category: "Rate Limiting", - }; - } - - // Server errors (retryable) - if ( - message.includes("500") || - message.includes("502") || - message.includes("503") || - message.includes("server error") - ) { - return { - type: "server_error", - retryable: true, - category: "Server Errors", - }; - } - - // Authentication errors (not retryable) - if ( - message.includes("unauthorized") || - message.includes("401") || - message.includes("authentication") - ) { - return { - type: "authentication_error", - retryable: false, - category: "Authentication", - }; - } - - // Permission errors (not retryable) - if ( - message.includes("forbidden") || - message.includes("403") || - message.includes("permission") - ) { - return { - type: "permission_error", - retryable: false, - category: "Permissions", - }; - } - - // Data validation errors (not retryable) - if ( - message.includes("invalid") || - message.includes("validation") || - message.includes("price") || - message.includes("compare-at") - ) { - return { - type: "validation_error", - retryable: false, - category: "Data Validation", - }; - } - - // Resource not found errors (not retryable) - if (message.includes("not found") || message.includes("404")) { - return { - type: "not_found_error", - retryable: false, - category: "Resource Not Found", - }; - } - - // Shopify API specific errors - if (message.includes("shopify") && message.includes("api")) { - return { - type: "shopify_api_error", - retryable: false, - category: "Shopify API", - }; - } - - // Unknown errors (potentially retryable) - return { - type: "unknown_error", - retryable: true, - category: "Other", - }; - } - - /** - * Utility function to add delay between operations - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - async delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} - -module.exports = ProductService; - - - -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. - - -