diff --git a/.aicodeprep-gui b/.aicodeprep-gui new file mode 100644 index 0000000..9f84663 --- /dev/null +++ b/.aicodeprep-gui @@ -0,0 +1,55 @@ +# .aicodeprep-gui LLM/AI context helper settings file +# This file stores your preferences (checked code files, window size) for this folder. +# Generated by aicodeprep-gui. +# Homepage: https://wuu73.org/aicp +# GitHub: https://github.com/detroittommy879/aicodeprep-gui +# ---------------------------------------------------------- +# aicodeprep-gui preferences file version 1.0 +version=1.0 + +[window] +width=1920 +height=1009 +splitter_state=AAAA/wAAAAEAAAACAAADAAAAAPwB/////wEAAAACAA== + +[format] +output_format=xml + +[files] +.env.example +package.json +README.md +.kiro\specs\windows-compatible-tui\design.md +.kiro\specs\windows-compatible-tui\requirements.md +.kiro\specs\windows-compatible-tui\tasks.md +.kiro\steering\product.md +.kiro\steering\structure.md +.kiro\steering\tech.md +backend\.env +docs\enhanced-signal-handling.md +src\index.js +src\tui-entry.js +src\config\environment.js +src\services\product.js +src\services\progress.js +src\services\schedule.js +src\services\shopify.js +src\tui\TuiApplication.jsx +src\tui\components\Router.jsx +src\tui\components\StatusBar.jsx +src\tui\components\common\ProgressBar.js +src\tui\providers\AppProvider.jsx +src\utils\logger.js +src\utils\price.js +tests\index.test.js +tests\config\environment.test.js +tests\integration\rollback-workflow.test.js +tests\integration\scheduled-execution-workflow.test.js +tests\services\product.test.js +tests\services\progress.test.js +tests\services\schedule-error-handling.test.js +tests\services\schedule-signal-handling.test.js +tests\services\schedule.test.js +tests\services\shopify.test.js +tests\utils\logger.test.js +tests\utils\price.test.js diff --git a/fullcode.txt b/fullcode.txt new file mode 100644 index 0000000..fa77ba5 --- /dev/null +++ b/fullcode.txt @@ -0,0 +1,16032 @@ +.kiro\specs\windows-compatible-tui\design.md: + +# Design Document + +## Overview + +This design document outlines the replacement of the Blessed-based TUI with a Windows-compatible alternative using **Ink** (React for CLI) as the primary library choice. Ink provides excellent cross-platform support, modern React-based component architecture, and superior Windows compatibility compared to Blessed. The design maintains all existing functionality while improving performance, maintainability, and user experience across all platforms. + +## Architecture + +### Library Selection: Ink (React for CLI) + +**Primary Choice: Ink v4.x** + +- **Rationale**: Ink is built on React principles, providing a modern component-based architecture +- **Windows Compatibility**: Excellent support for Windows Terminal, Command Prompt, and PowerShell +- **Performance**: Uses React's reconciliation for efficient updates, reducing flicker +- **Ecosystem**: Large ecosystem of pre-built components and utilities +- **Maintenance**: Actively maintained by Vercel with strong community support + +**Alternative Considerations**: + +- **Blessed**: Current library with Windows issues (being replaced) +- **Terminal-kit**: Good Windows support but more complex API +- **Enquirer**: Limited to prompts, not full TUI applications +- **Neo-blessed**: Fork of Blessed with some improvements but still has Windows issues + +### Component Architecture + +``` +TuiApplication (Root) +├── AppProvider (Context/State Management) +├── Router (Screen Management) +├── StatusBar (Global Status) +└── Screens/ + ├── MainMenuScreen + ├── ConfigurationScreen + ├── OperationScreen + ├── SchedulingScreen + ├── LogViewerScreen + └── TagAnalysisScreen +``` + +### State Management + +Using React Context API with custom hooks for: + +- Application state (current screen, navigation history) +- Configuration state (environment variables, settings) +- Operation state (progress, results, errors) +- UI state (focus, selections, modal states) + +## Components and Interfaces + +### Core Components + +#### 1. TuiApplication (Root Component) + +```javascript +const TuiApplication = () => { + return ( + + + + + + + ); +}; +``` + +#### 2. AppProvider (State Management) + +```javascript +const AppProvider = ({ children }) => { + const [appState, setAppState] = useState({ + currentScreen: "main-menu", + navigationHistory: [], + configuration: {}, + operationState: null, + }); + + return ( + + {children} + + ); +}; +``` + +#### 3. Router (Screen Management) + +```javascript +const Router = () => { + const { appState } = useContext(AppContext); + + const screens = { + "main-menu": MainMenuScreen, + configuration: ConfigurationScreen, + operation: OperationScreen, + scheduling: SchedulingScreen, + logs: LogViewerScreen, + "tag-analysis": TagAnalysisScreen, + }; + + const CurrentScreen = screens[appState.currentScreen]; + return ; +}; +``` + +#### 4. StatusBar (Global Status Display) + +```javascript +const StatusBar = () => { + const { connectionStatus, operationProgress } = useAppState(); + + return ( + + ● Connected + | + Progress: {operationProgress}% + + ); +}; +``` + +### Screen Components + +#### MainMenuScreen + +- Navigation menu with keyboard shortcuts +- Current configuration summary +- Quick action buttons +- Help information + +#### ConfigurationScreen + +- Environment variable editor +- Input validation with real-time feedback +- API connection testing +- Save/cancel operations + +#### OperationScreen + +- Operation type selection (update/rollback) +- Real-time progress display +- Product processing information +- Error handling and display + +#### SchedulingScreen + +- Date/time picker interface +- Schedule management +- Countdown display +- Cancellation controls + +#### LogViewerScreen + +- Paginated log display +- Search and filtering +- Log entry details +- Export functionality + +#### TagAnalysisScreen + +- Tag listing and statistics +- Product count per tag +- Sample product display +- Recommendations + +### Reusable UI Components + +#### ProgressBar + +```javascript +const ProgressBar = ({ progress, label, color = "blue" }) => { + const width = 40; + const filled = Math.round((progress / 100) * width); + + return ( + + {label} + + {"█".repeat(filled)} + {"░".repeat(width - filled)} + {progress}% + + + ); +}; +``` + +#### InputField + +```javascript +const InputField = ({ label, value, onChange, validation, placeholder }) => { + const [isValid, setIsValid] = useState(true); + + return ( + + {label}: + { + onChange(val); + setIsValid(validation ? validation(val) : true); + }} + placeholder={placeholder} + /> + {!isValid && Invalid input} + + ); +}; +``` + +#### MenuList + +```javascript +const MenuList = ({ items, selectedIndex, onSelect }) => { + return ( + + {items.map((item, index) => ( + + + {index === selectedIndex ? "► " : " "} + {item.label} + + + ))} + + ); +}; +``` + +## Data Models + +### Application State + +```javascript +interface AppState { + currentScreen: string; + navigationHistory: string[]; + configuration: ConfigurationState; + operationState: OperationState | null; + uiState: UIState; +} + +interface ConfigurationState { + shopifyDomain: string; + accessToken: string; + targetTag: string; + priceAdjustment: number; + operationMode: "update" | "rollback"; + isValid: boolean; + lastTested: Date | null; +} + +interface OperationState { + type: "update" | "rollback" | "scheduled"; + status: "idle" | "running" | "completed" | "error"; + progress: number; + currentProduct: string | null; + results: OperationResults | null; + errors: Error[]; +} + +interface UIState { + focusedComponent: string; + modalOpen: boolean; + selectedMenuIndex: number; + scrollPosition: number; +} +``` + +### Service Integration + +```javascript +interface ServiceIntegration { + shopifyService: ShopifyService; + productService: ProductService; + progressService: ProgressService; + configService: ConfigurationService; +} +``` + +## Error Handling + +### Error Categories + +1. **Configuration Errors**: Invalid environment variables, API credentials +2. **Network Errors**: Connection failures, timeout issues +3. **API Errors**: Shopify API rate limits, authentication failures +4. **UI Errors**: Component rendering issues, state inconsistencies +5. **System Errors**: File system access, permission issues + +### Error Display Strategy + +```javascript +const ErrorBoundary = ({ children }) => { + const [error, setError] = useState(null); + + if (error) { + return ( + + + Error Occurred + + {error.message} + Press 'r' to retry or 'q' to quit + + ); + } + + return children; +}; +``` + +### Graceful Degradation + +- Fallback to basic text display if advanced features fail +- Automatic retry mechanisms for network operations +- State persistence to recover from crashes +- Clear error messages with suggested actions + +## Testing Strategy + +### Component Testing + +```javascript +// Example test using Ink's testing utilities +import { render } from "ink-testing-library"; +import { MainMenuScreen } from "../screens/MainMenuScreen"; + +test("renders main menu with correct options", () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain("Price Update Operations"); + expect(lastFrame()).toContain("Configuration"); + expect(lastFrame()).toContain("View Logs"); +}); +``` + +### Integration Testing + +- Test service integration with mock services +- Verify state management across screen transitions +- Test keyboard navigation and input handling +- Validate error handling scenarios + +### Cross-Platform Testing + +- Automated testing on Windows, macOS, and Linux +- Terminal compatibility testing (Windows Terminal, Command Prompt, PowerShell) +- Unicode and color support verification +- Performance testing with large datasets + +## Migration Strategy + +### Phase 1: Setup and Core Infrastructure + +1. Install Ink and related dependencies +2. Create basic application structure +3. Implement state management system +4. Set up routing and navigation + +### Phase 2: Screen Implementation + +1. Implement MainMenuScreen (simplest) +2. Create ConfigurationScreen with form handling +3. Build OperationScreen with progress display +4. Add remaining screens (Scheduling, Logs, TagAnalysis) + +### Phase 3: Component Migration + +1. Replace Blessed ProgressBar with Ink version +2. Migrate form components and input handling +3. Update navigation and keyboard shortcuts +4. Implement error handling and validation + +### Phase 4: Testing and Refinement + +1. Comprehensive testing on Windows systems +2. Performance optimization and bug fixes +3. Documentation updates +4. Legacy code cleanup + +### Dependency Changes + +```json +{ + "dependencies": { + "ink": "^4.4.1", + "react": "^18.2.0", + "@ink/text-input": "^5.0.1", + "@ink/select-input": "^5.0.1", + "@ink/spinner": "^5.0.1" + }, + "devDependencies": { + "ink-testing-library": "^3.0.0" + } +} +``` + +### File Structure Changes + +``` +src/ +├── tui/ +│ ├── components/ +│ │ ├── common/ +│ │ │ ├── ProgressBar.jsx +│ │ │ ├── InputField.jsx +│ │ │ ├── MenuList.jsx +│ │ │ └── ErrorBoundary.jsx +│ │ ├── screens/ +│ │ │ ├── MainMenuScreen.jsx +│ │ │ ├── ConfigurationScreen.jsx +│ │ │ ├── OperationScreen.jsx +│ │ │ ├── SchedulingScreen.jsx +│ │ │ ├── LogViewerScreen.jsx +│ │ │ └── TagAnalysisScreen.jsx +│ │ └── providers/ +│ │ ├── AppProvider.jsx +│ │ └── ServiceProvider.jsx +│ ├── hooks/ +│ │ ├── useAppState.js +│ │ ├── useNavigation.js +│ │ └── useServices.js +│ ├── utils/ +│ │ ├── keyboardHandlers.js +│ │ └── validation.js +│ └── TuiApplication.jsx +└── tui-entry.js (new entry point) +``` + +## Performance Considerations + +### Rendering Optimization + +- Use React.memo for expensive components +- Implement virtual scrolling for large lists +- Debounce rapid state updates +- Minimize re-renders with proper state structure + +### Memory Management + +- Clean up event listeners and timers +- Implement proper component unmounting +- Use weak references for large data structures +- Monitor memory usage during long operations + +### Windows-Specific Optimizations + +- Use Windows-compatible Unicode characters +- Optimize for Windows Terminal performance +- Handle Windows-specific keyboard events +- Ensure proper color rendering in different terminals + +## Security Considerations + +### Input Validation + +- Sanitize all user inputs +- Validate configuration values +- Prevent injection attacks through input fields +- Secure handling of API credentials + +### State Security + +- Encrypt sensitive data in state +- Clear sensitive information on exit +- Prevent credential logging +- Secure temporary file handling + +This design provides a robust foundation for replacing Blessed with Ink, ensuring excellent Windows compatibility while maintaining all existing functionality and improving the overall user experience. + + + +.kiro\specs\windows-compatible-tui\requirements.md: + +# Requirements Document + +## Introduction + +This document outlines the requirements for replacing the existing Blessed-based Terminal User Interface (TUI) with a Windows-compatible alternative. The current TUI implementation using the Blessed library has compatibility issues on Windows systems, requiring a migration to a more robust, cross-platform TUI library that provides better Windows support while maintaining all existing functionality and user experience expectations. + +## Requirements + +### Requirement 1 + +**User Story:** As a Windows user, I want a TUI that works reliably on my system, so that I can use the interactive interface without compatibility issues. + +#### Acceptance Criteria + +1. WHEN the TUI is launched on Windows THEN the system SHALL display correctly without rendering artifacts +2. WHEN using Windows Terminal or Command Prompt THEN the system SHALL handle keyboard input properly +3. WHEN the interface renders THEN the system SHALL display Unicode characters and colors correctly on Windows +4. WHEN resizing the terminal window THEN the system SHALL adapt the layout appropriately +5. WHEN using different Windows terminal emulators THEN the system SHALL maintain consistent behavior + +### Requirement 2 + +**User Story:** As a developer, I want to replace Blessed with a better cross-platform TUI library, so that the application works consistently across all operating systems. + +#### Acceptance Criteria + +1. WHEN selecting a replacement library THEN the system SHALL prioritize Windows compatibility +2. WHEN the new library is integrated THEN the system SHALL maintain feature parity with the Blessed implementation +3. WHEN the library is chosen THEN the system SHALL have active maintenance and good documentation +4. WHEN implementing the replacement THEN the system SHALL support modern terminal features +5. WHEN the migration is complete THEN the system SHALL remove all Blessed dependencies + +### Requirement 3 + +**User Story:** As a user, I want the same TUI functionality after the library replacement, so that my workflow remains unchanged. + +#### Acceptance Criteria + +1. WHEN the new TUI loads THEN the system SHALL display the same main menu structure +2. WHEN navigating the interface THEN the system SHALL support the same keyboard shortcuts +3. WHEN configuring settings THEN the system SHALL provide the same configuration options +4. WHEN running operations THEN the system SHALL show the same progress indicators +5. WHEN viewing logs THEN the system SHALL display the same information format + +### Requirement 4 + +**User Story:** As a user, I want improved performance and responsiveness in the new TUI, so that the interface feels more fluid and responsive. + +#### Acceptance Criteria + +1. WHEN the TUI starts THEN the system SHALL load faster than the Blessed version +2. WHEN updating progress displays THEN the system SHALL render smoothly without flickering +3. WHEN handling large amounts of log data THEN the system SHALL maintain responsive scrolling +4. WHEN switching between screens THEN the system SHALL transition quickly +5. WHEN processing user input THEN the system SHALL respond immediately + +### Requirement 5 + +**User Story:** As a developer, I want the new TUI implementation to follow modern JavaScript patterns, so that the code is maintainable and extensible. + +#### Acceptance Criteria + +1. WHEN implementing components THEN the system SHALL use ES6+ features and modern patterns +2. WHEN structuring the code THEN the system SHALL follow the existing project architecture +3. WHEN handling state THEN the system SHALL use clear state management patterns +4. WHEN implementing event handling THEN the system SHALL use consistent event patterns +5. WHEN writing tests THEN the system SHALL provide good test coverage for TUI components + +### Requirement 6 + +**User Story:** As a user, I want enhanced visual feedback and better error handling in the new TUI, so that I have a clearer understanding of system status. + +#### Acceptance Criteria + +1. WHEN errors occur THEN the system SHALL display more informative error messages +2. WHEN operations are running THEN the system SHALL provide clearer progress visualization +3. WHEN configuration is invalid THEN the system SHALL highlight specific issues +4. WHEN API calls fail THEN the system SHALL show detailed connection status +5. WHEN the system is busy THEN the system SHALL provide appropriate loading indicators + +### Requirement 7 + +**User Story:** As a developer, I want the migration to preserve all existing service integrations, so that business logic remains unchanged. + +#### Acceptance Criteria + +1. WHEN the new TUI is implemented THEN the system SHALL reuse existing ShopifyService without changes +2. WHEN operations run THEN the system SHALL use existing ProductService and ProgressService +3. WHEN configuration is managed THEN the system SHALL use existing environment configuration +4. WHEN logs are generated THEN the system SHALL maintain compatibility with existing log formats +5. WHEN the migration is complete THEN the system SHALL pass all existing integration tests + +### Requirement 8 + +**User Story:** As a user, I want better accessibility features in the new TUI, so that the interface is more inclusive and easier to use. + +#### Acceptance Criteria + +1. WHEN using screen readers THEN the system SHALL provide appropriate text descriptions +2. WHEN using high contrast mode THEN the system SHALL adapt color schemes appropriately +3. WHEN using keyboard-only navigation THEN the system SHALL provide clear focus indicators +4. WHEN text is displayed THEN the system SHALL support different font sizes and terminal settings +5. WHEN colors are used THEN the system SHALL ensure sufficient contrast ratios + +### Requirement 9 + +**User Story:** As a developer, I want comprehensive documentation for the new TUI library choice, so that future maintenance is straightforward. + +#### Acceptance Criteria + +1. WHEN the library is selected THEN the system SHALL document the selection rationale +2. WHEN implementation patterns are established THEN the system SHALL document coding conventions +3. WHEN components are created THEN the system SHALL include inline documentation +4. WHEN the migration is complete THEN the system SHALL update all relevant README files +5. WHEN troubleshooting guides are needed THEN the system SHALL provide Windows-specific guidance + +### Requirement 10 + +**User Story:** As a user, I want the new TUI to handle terminal resizing and different screen sizes better, so that I can use it on various display configurations. + +#### Acceptance Criteria + +1. WHEN the terminal is resized THEN the system SHALL automatically adjust layout proportions +2. WHEN using small terminal windows THEN the system SHALL provide appropriate scrolling +3. WHEN using large displays THEN the system SHALL utilize available space effectively +4. WHEN switching between portrait and landscape orientations THEN the system SHALL adapt accordingly +5. WHEN minimum size requirements aren't met THEN the system SHALL display helpful guidance + +### Requirement 11 + +**User Story:** As a developer, I want a smooth migration path from Blessed to the new library, so that the transition minimizes disruption. + +#### Acceptance Criteria + +1. WHEN planning the migration THEN the system SHALL identify all Blessed-specific code +2. WHEN implementing replacements THEN the system SHALL maintain API compatibility where possible +3. WHEN testing the migration THEN the system SHALL verify functionality on multiple Windows versions +4. WHEN deploying the changes THEN the system SHALL provide fallback options if issues arise +5. WHEN the migration is complete THEN the system SHALL clean up all legacy Blessed code + +### Requirement 12 + +**User Story:** As a user, I want the new TUI to support modern terminal features, so that I can take advantage of enhanced terminal capabilities. + +#### Acceptance Criteria + +1. WHEN using modern terminals THEN the system SHALL support true color (24-bit) display +2. WHEN terminals support it THEN the system SHALL use enhanced Unicode characters +3. WHEN available THEN the system SHALL support mouse interaction for navigation +4. WHEN terminals provide it THEN the system SHALL use improved cursor positioning +5. WHEN modern features are unavailable THEN the system SHALL gracefully degrade functionality + + + +.kiro\specs\windows-compatible-tui\tasks.md: + +# Implementation Plan + +- [ ] 1. Setup Ink infrastructure and remove Blessed dependencies + + - Remove blessed dependency from package.json and install Ink dependencies + - Create new TUI entry point file that initializes Ink application + - Set up basic React component structure with JSX support + - _Requirements: 2.2, 2.5_ + +- [ ] 2. Implement core application structure and state management + + - Create AppProvider component with React Context for global state management + - Implement Router component for screen navigation and history management + - Create useAppState and useNavigation custom hooks for state access + - Write unit tests for state management and navigation logic + - _Requirements: 5.1, 5.3, 7.1_ + +- [ ] 3. Build reusable UI components +- [ ] 3.1 Create ProgressBar component with Ink + + - Replace Blessed ProgressBar with Ink-based implementation using Box and Text components + - Add support for different colors, labels, and progress indicators + - Write unit tests for ProgressBar component rendering and updates + - _Requirements: 3.1, 4.2, 6.2_ + +- [ ] 3.2 Implement InputField component with validation + + - Create InputField component using Ink's TextInput with validation support + - Add real-time validation feedback and error message display + - Write unit tests for input validation and error handling + - _Requirements: 3.2, 6.3, 8.3_ + +- [ ] 3.3 Create MenuList component for navigation + + - Implement MenuList component with keyboard navigation support + - Add selection highlighting and keyboard shortcut display + - Write unit tests for menu navigation and selection handling + - _Requirements: 1.2, 9.3, 9.4_ + +- [ ] 3.4 Build ErrorBoundary component for error handling + + - Create ErrorBoundary component to catch and display React errors gracefully + - Implement error recovery mechanisms and user-friendly error messages + - Write unit tests for error boundary functionality + - _Requirements: 6.1, 10.4, 11.4_ + +- [ ] 4. Implement StatusBar component + + - Create StatusBar component showing connection status and operation progress + - Integrate with existing services to display real-time system status + - Add support for different status indicators and colors + - Write unit tests for status display and updates + - _Requirements: 8.1, 8.2, 8.3_ + +- [ ] 5. Create MainMenuScreen component + + - Implement MainMenuScreen as the primary navigation interface + - Add keyboard shortcuts and menu options matching existing TUI requirements + - Integrate with navigation system for screen transitions + - Write unit tests for menu functionality and navigation + - _Requirements: 1.1, 1.3, 3.1, 9.1_ + +- [ ] 6. Build ConfigurationScreen component +- [ ] 6.1 Create configuration form interface + + - Implement ConfigurationScreen with form fields for all environment variables + - Add input validation and real-time feedback for configuration values + - Write unit tests for form validation and state management + - _Requirements: 2.1, 2.2, 2.4_ + +- [ ] 6.2 Implement configuration persistence + + - Add functionality to save configuration changes to .env file + - Implement configuration loading and validation on screen load + - Write unit tests for configuration file operations + - _Requirements: 2.3, 7.4, 11.4_ + +- [ ] 6.3 Add API connection testing + + - Integrate Shopify API connection testing within configuration screen + - Display connection status and error messages for failed connections + - Write unit tests for API connection testing functionality + - _Requirements: 2.5, 6.4, 8.1_ + +- [ ] 7. Implement OperationScreen component +- [ ] 7.1 Create operation selection interface + + - Build OperationScreen with update/rollback operation selection + - Display current configuration summary before operation execution + - Write unit tests for operation selection and configuration display + - _Requirements: 3.1, 4.1, 7.2_ + +- [ ] 7.2 Add real-time progress display + + - Implement real-time progress indicators using ProgressBar component + - Display current product information and processing status + - Write unit tests for progress display and updates + - _Requirements: 3.2, 3.3, 4.2, 8.2_ + +- [ ] 7.3 Integrate operation results display + + - Add results summary display for completed operations + - Implement error display panel for operation failures + - Write unit tests for results display and error handling + - _Requirements: 3.4, 3.5, 4.3, 6.1_ + +- [ ] 8. Build SchedulingScreen component +- [ ] 8.1 Create scheduling interface + + - Implement SchedulingScreen with date/time picker functionality + - Add schedule management and countdown timer display + - Write unit tests for scheduling interface and timer functionality + - _Requirements: 5.1, 5.2, 5.3_ + +- [ ] 8.2 Add schedule cancellation and notifications + + - Implement schedule cancellation with confirmation dialog + - Add visual notifications for approaching scheduled operations + - Write unit tests for cancellation and notification systems + - _Requirements: 5.4, 5.5_ + +- [ ] 9. Create LogViewerScreen component +- [ ] 9.1 Implement log display with pagination + + - Build LogViewerScreen with paginated log entry display + - Add scrolling support for large log files + - Write unit tests for log display and pagination + - _Requirements: 6.1, 6.4, 10.3_ + +- [ ] 9.2 Add log filtering and search functionality + + - Implement search and filtering capabilities for log entries + - Add detailed view for selected log entries + - Write unit tests for search and filtering functionality + - _Requirements: 6.2, 6.3_ + +- [ ] 9.3 Integrate automatic log refresh + + - Add automatic refresh functionality for active log monitoring + - Implement efficient update mechanisms to avoid performance issues + - Write unit tests for automatic refresh and performance + - _Requirements: 6.5, 4.3_ + +- [ ] 10. Build TagAnalysisScreen component +- [ ] 10.1 Create tag analysis interface + + - Implement TagAnalysisScreen displaying available product tags and counts + - Add sample product display for selected tags + - Write unit tests for tag analysis display and selection + - _Requirements: 7.1, 7.2, 7.3_ + +- [ ] 10.2 Add tag recommendations + + - Implement recommendation system for optimal target tags + - Display analysis results and suggestions to users + - Write unit tests for recommendation logic and display + - _Requirements: 7.4_ + +- [ ] 11. Implement keyboard navigation and shortcuts +- [ ] 11.1 Add global keyboard handlers + + - Create keyboard event handlers for navigation and shortcuts + - Implement consistent back/exit functionality across all screens + - Write unit tests for keyboard navigation and event handling + - _Requirements: 9.1, 9.3, 9.4_ + +- [ ] 11.2 Create help system + + - Implement help overlay displaying available shortcuts and navigation + - Add context-sensitive help for different screens + - Write unit tests for help system functionality + - _Requirements: 9.2, 9.5_ + +- [ ] 12. Integrate with existing services +- [ ] 12.1 Connect TUI to ShopifyService + + - Integrate TUI components with existing ShopifyService for API operations + - Ensure all API calls use existing service methods without modification + - Write integration tests for service connectivity + - _Requirements: 7.1, 12.1_ + +- [ ] 12.2 Connect TUI to ProductService and ProgressService + + - Integrate TUI with existing ProductService for product operations + - Connect ProgressService for logging and progress tracking + - Write integration tests for service integration + - _Requirements: 7.2, 12.2, 12.3_ + +- [ ] 12.3 Maintain CLI compatibility + + - Ensure TUI implementation doesn't break existing CLI functionality + - Verify that both interfaces can coexist and use same configuration + - Write integration tests for CLI/TUI compatibility + - _Requirements: 12.3, 12.4_ + +- [ ] 13. Implement responsive layout and terminal handling +- [ ] 13.1 Add terminal resize handling + + - Implement automatic layout adjustment for terminal resize events + - Add minimum size requirements and appropriate messaging + - Write unit tests for resize handling and layout adaptation + - _Requirements: 10.1, 10.2, 10.5_ + +- [ ] 13.2 Optimize for different screen sizes + + - Implement responsive design for small and large terminal windows + - Add scrolling support where needed for content overflow + - Write unit tests for different screen size scenarios + - _Requirements: 10.2, 10.3, 10.4_ + +- [ ] 14. Add accessibility and modern terminal features +- [ ] 14.1 Implement accessibility features + + - Add screen reader support and high contrast mode compatibility + - Implement clear focus indicators for keyboard navigation + - Write tests for accessibility features + - _Requirements: 8.1, 8.2, 8.3_ + +- [ ] 14.2 Add modern terminal feature support + + - Implement true color support and enhanced Unicode character usage + - Add mouse interaction support where appropriate + - Write tests for modern terminal feature detection and usage + - _Requirements: 12.1, 12.2, 12.3_ + +- [ ] 15. Performance optimization and testing +- [ ] 15.1 Optimize rendering performance + + - Implement React.memo for expensive components and virtual scrolling for large lists + - Add debouncing for rapid state updates and minimize unnecessary re-renders + - Write performance tests and benchmarks + - _Requirements: 4.1, 4.3, 4.4_ + +- [ ] 15.2 Add memory management + + - Implement proper cleanup for event listeners and timers + - Add memory usage monitoring for long-running operations + - Write tests for memory leak detection and cleanup + - _Requirements: 4.2, 4.5_ + +- [ ] 16. Cross-platform testing and Windows optimization +- [ ] 16.1 Test Windows compatibility + + - Run comprehensive tests on Windows Terminal, Command Prompt, and PowerShell + - Verify Unicode character rendering and color support on Windows + - Write Windows-specific integration tests + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + +- [ ] 16.2 Optimize for Windows performance + + - Implement Windows-specific optimizations for terminal rendering + - Add Windows-specific keyboard event handling + - Write performance tests specifically for Windows environments + - _Requirements: 1.5, 4.4_ + +- [ ] 17. Documentation and migration cleanup +- [ ] 17.1 Update documentation + + - Update README files with new TUI library information and setup instructions + - Document new component architecture and development patterns + - Create troubleshooting guide for Windows-specific issues + - _Requirements: 9.1, 9.2, 9.4_ + +- [ ] 17.2 Clean up legacy Blessed code + - Remove all Blessed dependencies and related code files + - Clean up any remaining references to Blessed in documentation + - Verify complete migration through final testing + - _Requirements: 2.5, 11.5_ + + + +.kiro\steering\product.md: + +# Product Overview + +## Shopify Price Updater + +A Node.js command-line tool for bulk updating Shopify product prices based on product tags using Shopify's GraphQL Admin API. + +### Core Features + +- **Tag-based filtering**: Updates prices only for products with specific tags +- **Dual operation modes**: + - `update`: Adjusts prices by percentage and sets compare-at prices + - `rollback`: Reverts prices using compare-at price values +- **Batch processing**: Handles large inventories with automatic pagination +- **Error resilience**: Continues processing with comprehensive error handling +- **Rate limit handling**: Automatic retry logic with exponential backoff +- **Progress tracking**: Detailed logging to console and Progress.md file + +### Target Users + +- Shopify store owners managing promotional pricing +- E-commerce teams running seasonal sales campaigns +- Developers automating price management workflows + +### Key Value Propositions + +- Reduces manual price update effort from hours to minutes +- Provides rollback capability for promotional campaigns +- Maintains data integrity with validation and error handling +- Offers detailed audit trails through comprehensive logging + + + +.kiro\steering\structure.md: + +# Project Structure + +## Directory Organization + +``` +shopify-price-updater/ +├── src/ # Main application source code +│ ├── config/ # Configuration management +│ │ └── environment.js # Environment variable validation & loading +│ ├── services/ # Business logic services +│ │ ├── shopify.js # Shopify API client & GraphQL operations +│ │ ├── product.js # Product operations & price calculations +│ │ └── progress.js # Progress tracking & logging service +│ ├── utils/ # Utility functions +│ │ ├── price.js # Price calculation & validation utilities +│ │ └── logger.js # Logging utilities & formatters +│ └── index.js # Main application entry point +├── tests/ # Test suites +│ ├── config/ # Configuration tests +│ ├── services/ # Service layer tests +│ ├── utils/ # Utility function tests +│ ├── integration/ # Integration tests +│ └── index.test.js # Main application tests +├── backend/ # Separate backend component (minimal) +│ └── .env # Backend-specific environment config +├── .env # Main environment configuration +├── .env.example # Environment template +├── Progress.md # Generated operation logs +├── debug-tags.js # Standalone tag analysis script +└── test-*.js # Individual test scripts +``` + +## Code Organization Patterns + +### Service Layer Structure + +- **ShopifyService**: API client, authentication, retry logic +- **ProductService**: Product fetching, validation, price updates +- **ProgressService**: Logging, progress tracking, file operations + +### Configuration Management + +- Centralized in `src/config/environment.js` +- Validation at startup with clear error messages +- Environment-specific defaults and overrides + +### Error Handling Strategy + +- Service-level error categorization (rate limits, network, validation) +- Comprehensive retry logic with exponential backoff +- Detailed error logging with context preservation + +### Testing Structure + +- Unit tests for utilities and individual services +- Integration tests for complete workflows +- Mock-based testing for external API dependencies +- Separate test files for different operation modes + +## File Naming Conventions + +- **Services**: `[domain].js` (e.g., `shopify.js`, `product.js`) +- **Utilities**: `[function].js` (e.g., `price.js`, `logger.js`) +- **Tests**: `[module].test.js` or `test-[feature].js` +- **Configuration**: Descriptive names (e.g., `environment.js`) + +## Entry Points + +- **Main Application**: `src/index.js` - Complete price update workflow +- **Debug Tools**: `debug-tags.js` - Tag analysis and troubleshooting +- **Test Scripts**: `test-*.js` - Individual feature testing + + + +.kiro\steering\tech.md: + +# Technology Stack + +## Runtime & Dependencies + +- **Node.js**: >=16.0.0 (specified in package.json engines) +- **Core Dependencies**: + - `@shopify/shopify-api`: ^7.7.0 - Shopify GraphQL API client + - `dotenv`: ^16.3.1 - Environment variable management + - `node-fetch`: ^3.3.2 - HTTP client for API requests +- **Development Dependencies**: + - `jest`: ^29.7.0 - Testing framework + +## Architecture Patterns + +- **Service Layer Architecture**: Separation of concerns with dedicated services +- **Configuration Management**: Centralized environment-based configuration +- **Error Handling**: Comprehensive retry logic with exponential backoff +- **Logging Strategy**: Dual output (console + Progress.md file) + +## API Integration + +- **Shopify GraphQL Admin API**: Version 2024-01 +- **Authentication**: Private App access tokens +- **Rate Limiting**: Built-in handling with automatic retries +- **Bulk Operations**: Uses `productVariantsBulkUpdate` mutation + +## Common Commands + +### Development + +```bash +npm install # Install dependencies +npm start # Run with default settings +npm run update # Explicit update mode +npm run rollback # Rollback mode +npm run debug-tags # Debug tag analysis +npm test # Run test suite +``` + +### Environment Setup + +```bash +copy .env.example .env # Create environment file (Windows) +cp .env.example .env # Create environment file (Unix) +``` + +### Testing & Debugging + +```bash +npm run debug-tags # Analyze store tags before running +npm test # Run Jest test suite +``` + +## Environment Variables + +Required variables (see .env.example): + +- `SHOPIFY_SHOP_DOMAIN`: Store domain +- `SHOPIFY_ACCESS_TOKEN`: Admin API token +- `TARGET_TAG`: Product tag filter +- `PRICE_ADJUSTMENT_PERCENTAGE`: Adjustment percentage +- `OPERATION_MODE`: "update" or "rollback" + + + +backend\.env: + +# Shopify App Configuration +SHOPIFY_API_KEY=your_api_key_here +SHOPIFY_API_SECRET=your_api_secret_here +SHOPIFY_SCOPES=write_products,read_products +SHOPIFY_APP_URL=http://localhost:3000 +HOST=localhost:3000 + +# Database Configuration +DATABASE_URL=sqlite:./database.sqlite + +# Server Configuration +PORT=3000 +NODE_ENV=development + +# Session Configuration +SESSION_SECRET=your_session_secret_here + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 + + +docs\enhanced-signal-handling.md: + +# Enhanced Signal Handling for Scheduled Operations + +## Overview + +The enhanced signal handling system provides intelligent cancellation support for scheduled price update operations. It distinguishes between different phases of execution and responds appropriately to interrupt signals (SIGINT/SIGTERM). + +## Features + +### 1. Phase-Aware Cancellation + +The system recognizes three distinct phases: + +- **Scheduling Wait Period**: Cancellation is allowed and encouraged +- **Price Update Operations**: Cancellation is prevented to avoid data corruption +- **Normal Operations**: Standard graceful shutdown behavior + +### 2. Clear User Feedback + +When users press Ctrl+C during different phases, they receive clear, contextual messages: + +#### During Scheduling Wait Period + +``` +🛑 Received SIGINT during scheduled wait period. +📋 Cancelling scheduled operation... +✅ Scheduled operation cancelled successfully. No price updates were performed. +``` + +#### During Price Update Operations + +``` +⚠️ Received SIGINT during active price update operations. +🔒 Cannot cancel while price updates are in progress to prevent data corruption. +⏳ Please wait for current operations to complete... +💡 Tip: You can cancel during the countdown period before operations begin. +``` + +### 3. State Coordination + +The system uses state management to coordinate between the main application and the ScheduleService: + +- `schedulingActive`: Indicates if the system is in the scheduling wait period +- `operationInProgress`: Indicates if price update operations are running + +## Implementation Details + +### Main Application Signal Handlers + +Located in `src/index.js`, the enhanced signal handlers: + +1. Check the current phase (scheduling vs. operations) +2. Provide appropriate user feedback +3. Coordinate with the ScheduleService for cleanup +4. Prevent interruption during critical operations + +### ScheduleService Integration + +The `ScheduleService` (`src/services/schedule.js`) has been updated to: + +1. Remove its own signal handling (delegated to main application) +2. Support external cancellation through the `cleanup()` method +3. Provide proper resource cleanup and state management + +### State Management Functions + +The main application provides state management functions to the ShopifyPriceUpdater instance: + +- `setSchedulingActive(boolean)`: Updates scheduling phase state +- `setOperationInProgress(boolean)`: Updates operation phase state + +## Usage Examples + +### Scheduled Operation with Cancellation + +```bash +# Set scheduled execution time +export SCHEDULED_EXECUTION_TIME="2025-08-07T15:30:00" + +# Run the application +npm start + +# During countdown, press Ctrl+C to cancel +# Result: Clean cancellation with confirmation message +``` + +### Scheduled Operation During Updates + +```bash +# Same setup, but let the countdown complete +# Once price updates begin, press Ctrl+C +# Result: Cancellation is prevented with explanation +``` + +## Testing + +The enhanced signal handling is thoroughly tested in: + +- `tests/services/schedule-signal-handling.test.js`: Unit tests for all requirements +- Tests cover all three requirements: + - 3.1: Cancellation during wait period + - 3.2: Clear cancellation confirmation messages + - 3.3: No interruption once operations begin + +## Requirements Compliance + +### Requirement 3.1: Cancellation Support + +✅ **IMPLEMENTED**: System responds to SIGINT and SIGTERM during wait period + +### Requirement 3.2: Clear Confirmation Messages + +✅ **IMPLEMENTED**: Contextual messages inform users about cancellation status + +### Requirement 3.3: Operation Protection + +✅ **IMPLEMENTED**: Price updates cannot be interrupted to prevent data corruption + +## Benefits + +1. **Data Integrity**: Prevents partial updates that could corrupt pricing data +2. **User Experience**: Clear feedback about what's happening and what's possible +3. **Flexibility**: Users can cancel during planning phase but not during execution +4. **Reliability**: Proper resource cleanup and state management + + + +src\config\environment.js: + +const dotenv = require("dotenv"); + +// Load environment variables from .env file +dotenv.config(); + +/** + * Validates and loads environment configuration + * @returns {Object} Configuration object with validated environment variables + * @throws {Error} If required environment variables are missing or invalid + */ +function loadEnvironmentConfig() { + const config = { + shopDomain: process.env.SHOPIFY_SHOP_DOMAIN, + accessToken: process.env.SHOPIFY_ACCESS_TOKEN, + targetTag: process.env.TARGET_TAG, + priceAdjustmentPercentage: process.env.PRICE_ADJUSTMENT_PERCENTAGE, + operationMode: process.env.OPERATION_MODE || "update", // Default to "update" for backward compatibility + scheduledExecutionTime: process.env.SCHEDULED_EXECUTION_TIME || null, // Optional scheduling parameter + }; + + // Validate required environment variables + const requiredVars = [ + { + key: "SHOPIFY_SHOP_DOMAIN", + value: config.shopDomain, + name: "Shopify shop domain", + }, + { + key: "SHOPIFY_ACCESS_TOKEN", + value: config.accessToken, + name: "Shopify access token", + }, + { key: "TARGET_TAG", value: config.targetTag, name: "Target product tag" }, + { + key: "PRICE_ADJUSTMENT_PERCENTAGE", + value: config.priceAdjustmentPercentage, + name: "Price adjustment percentage", + }, + ]; + + const missingVars = []; + + for (const variable of requiredVars) { + if (!variable.value || variable.value.trim() === "") { + missingVars.push(variable.key); + } + } + + if (missingVars.length > 0) { + const errorMessage = + `Missing required environment variables: ${missingVars.join(", ")}\n` + + "Please check your .env file and ensure all required variables are set.\n" + + "See .env.example for reference."; + throw new Error(errorMessage); + } + + // Validate and convert price adjustment percentage + const percentage = parseFloat(config.priceAdjustmentPercentage); + if (isNaN(percentage)) { + throw new Error( + `Invalid PRICE_ADJUSTMENT_PERCENTAGE: "${config.priceAdjustmentPercentage}". Must be a valid number.` + ); + } + + // Validate shop domain format + if ( + !config.shopDomain.includes(".myshopify.com") && + !config.shopDomain.includes(".") + ) { + throw new Error( + `Invalid SHOPIFY_SHOP_DOMAIN: "${config.shopDomain}". Must be a valid Shopify domain (e.g., your-shop.myshopify.com)` + ); + } + + // Validate access token format (basic check) + if (config.accessToken.length < 10) { + throw new Error( + "Invalid SHOPIFY_ACCESS_TOKEN: Token appears to be too short. Please verify your access token." + ); + } + + // Validate target tag is not empty after trimming + const trimmedTag = config.targetTag.trim(); + if (trimmedTag === "") { + throw new Error( + "Invalid TARGET_TAG: Tag cannot be empty or contain only whitespace." + ); + } + + // Validate operation mode + const validOperationModes = ["update", "rollback"]; + if (!validOperationModes.includes(config.operationMode)) { + throw new Error( + `Invalid OPERATION_MODE: "${config.operationMode}". Must be either "update" or "rollback".` + ); + } + + // Validate scheduled execution time if provided using enhanced ScheduleService validation + let scheduledTime = null; + let isScheduled = false; + + if (config.scheduledExecutionTime) { + // Use ScheduleService for comprehensive validation and error handling + const ScheduleService = require("../services/schedule"); + const Logger = require("../utils/logger"); + + // Create a temporary schedule service instance for validation + const tempLogger = new Logger(); + const scheduleService = new ScheduleService(tempLogger); + + try { + // Use the enhanced validation from ScheduleService + scheduledTime = scheduleService.parseScheduledTime( + config.scheduledExecutionTime + ); + isScheduled = true; + } catch (error) { + // Re-throw with environment configuration context + throw new Error( + `SCHEDULED_EXECUTION_TIME validation failed:\n${error.message}` + ); + } + } + + // Return validated configuration + return { + shopDomain: config.shopDomain.trim(), + accessToken: config.accessToken.trim(), + targetTag: trimmedTag, + priceAdjustmentPercentage: percentage, + operationMode: config.operationMode, + scheduledExecutionTime: scheduledTime, + isScheduled: isScheduled, + }; +} + +/** + * Get validated environment configuration + * Caches the configuration after first load + */ +let cachedConfig = null; + +function getConfig() { + if (!cachedConfig) { + try { + cachedConfig = loadEnvironmentConfig(); + } catch (error) { + console.error("Environment Configuration Error:"); + console.error(error.message); + process.exit(1); + } + } + return cachedConfig; +} + +module.exports = { + getConfig, + loadEnvironmentConfig, // Export for testing purposes +}; + + + +src\services\product.js: + +const ShopifyService = require("./shopify"); +const { + calculateNewPrice, + preparePriceUpdate, + validateRollbackEligibility, + prepareRollbackUpdate, +} = require("../utils/price"); +const Logger = require("../utils/logger"); + +/** + * Product service for querying and updating Shopify products + * Handles product fetching by tag and price updates + */ +class ProductService { + constructor() { + this.shopifyService = new ShopifyService(); + this.logger = new Logger(); + this.pageSize = 50; // Shopify recommends max 250, but 50 is safer for rate limits + this.batchSize = 10; // Process variants in batches to manage rate limits + } + + /** + * GraphQL query to fetch products by tag with pagination + */ + getProductsByTagQuery() { + return ` + query getProductsByTag($query: String!, $first: Int!, $after: String) { + products(first: $first, after: $after, query: $query) { + edges { + node { + id + title + tags + variants(first: 100) { + edges { + node { + id + price + compareAtPrice + title + } + } + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + `; + } + + /** + * GraphQL query to fetch all products (for debugging tag issues) + */ + getAllProductsQuery() { + return ` + query getAllProducts($first: Int!, $after: String) { + products(first: $first, after: $after) { + edges { + node { + id + title + tags + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + `; + } + + /** + * GraphQL mutation to update product variant price and Compare At price + */ + getProductVariantUpdateMutation() { + return ` + mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { + productVariantsBulkUpdate(productId: $productId, variants: $variants) { + productVariants { + id + price + compareAtPrice + } + userErrors { + field + message + } + } + } + `; + } + + /** + * Fetch all products with the specified tag using cursor-based pagination + * @param {string} tag - Tag to filter products by + * @returns {Promise} Array of products with their variants + */ + async fetchProductsByTag(tag) { + await this.logger.info(`Starting to fetch products with tag: ${tag}`); + + const allProducts = []; + let hasNextPage = true; + let cursor = null; + let pageCount = 0; + + try { + while (hasNextPage) { + pageCount++; + await this.logger.info(`Fetching page ${pageCount} of products...`); + + const queryString = tag.startsWith("tag:") ? tag : `tag:${tag}`; + const variables = { + query: queryString, // Shopify query format for tags + first: this.pageSize, + after: cursor, + }; + + await this.logger.info(`Using GraphQL query string: "${queryString}"`); + + const response = await this.shopifyService.executeWithRetry( + () => + this.shopifyService.executeQuery( + this.getProductsByTagQuery(), + variables + ), + this.logger + ); + + if (!response.products) { + throw new Error("Invalid response structure: missing products field"); + } + + const { edges, pageInfo } = response.products; + + // Process products from this page + const pageProducts = edges.map((edge) => ({ + id: edge.node.id, + title: edge.node.title, + tags: edge.node.tags, + variants: edge.node.variants.edges.map((variantEdge) => ({ + id: variantEdge.node.id, + price: parseFloat(variantEdge.node.price), + compareAtPrice: variantEdge.node.compareAtPrice + ? parseFloat(variantEdge.node.compareAtPrice) + : null, + title: variantEdge.node.title, + })), + })); + + allProducts.push(...pageProducts); + await this.logger.info( + `Found ${pageProducts.length} products on page ${pageCount}` + ); + + // Update pagination info + hasNextPage = pageInfo.hasNextPage; + cursor = pageInfo.endCursor; + + // Log progress for large datasets + if (allProducts.length > 0 && allProducts.length % 100 === 0) { + await this.logger.info( + `Total products fetched so far: ${allProducts.length}` + ); + } + } + + await this.logger.info( + `Successfully fetched ${allProducts.length} products with tag: ${tag}` + ); + + // Log variant count for additional context + const totalVariants = allProducts.reduce( + (sum, product) => sum + product.variants.length, + 0 + ); + await this.logger.info(`Total product variants found: ${totalVariants}`); + + return allProducts; + } catch (error) { + await this.logger.error( + `Failed to fetch products with tag ${tag}: ${error.message}` + ); + throw new Error(`Product fetching failed: ${error.message}`); + } + } + + /** + * Validate that products have the required data for price updates + * @param {Array} products - Array of products to validate + * @returns {Promise} Array of valid products + */ + async validateProducts(products) { + const validProducts = []; + let skippedCount = 0; + + for (const product of products) { + // Check if product has variants + if (!product.variants || product.variants.length === 0) { + await this.logger.warning( + `Skipping product "${product.title}" - no variants found` + ); + skippedCount++; + continue; + } + + // Check if variants have valid price data + const validVariants = []; + for (const variant of product.variants) { + if (typeof variant.price !== "number" || isNaN(variant.price)) { + await this.logger.warning( + `Skipping variant "${variant.title}" in product "${product.title}" - invalid price: ${variant.price}` + ); + continue; + } + if (variant.price < 0) { + await this.logger.warning( + `Skipping variant "${variant.title}" in product "${product.title}" - negative price: ${variant.price}` + ); + continue; + } + validVariants.push(variant); + } + + if (validVariants.length === 0) { + await this.logger.warning( + `Skipping product "${product.title}" - no variants with valid prices` + ); + skippedCount++; + continue; + } + + // Add product with only valid variants + validProducts.push({ + ...product, + variants: validVariants, + }); + } + + if (skippedCount > 0) { + await this.logger.warning( + `Skipped ${skippedCount} products due to invalid data` + ); + } + + await this.logger.info( + `Validated ${validProducts.length} products for price updates` + ); + return validProducts; + } + + /** + * Validate that products have the required data for rollback operations + * Filters products to only include those with variants that have valid compare-at prices + * @param {Array} products - Array of products to validate for rollback + * @returns {Promise} Array of products eligible for rollback + */ + async validateProductsForRollback(products) { + const eligibleProducts = []; + let skippedProductCount = 0; + let totalVariantsProcessed = 0; + let eligibleVariantsCount = 0; + + await this.logger.info( + `Starting rollback validation for ${products.length} products` + ); + + for (const product of products) { + // Check if product has variants + if (!product.variants || product.variants.length === 0) { + await this.logger.warning( + `Skipping product "${product.title}" for rollback - no variants found` + ); + skippedProductCount++; + continue; + } + + // Check each variant for rollback eligibility + const eligibleVariants = []; + for (const variant of product.variants) { + totalVariantsProcessed++; + + const eligibilityResult = validateRollbackEligibility(variant); + + if (eligibilityResult.isEligible) { + eligibleVariants.push(variant); + eligibleVariantsCount++; + } else { + await this.logger.warning( + `Skipping variant "${variant.title}" in product "${product.title}" for rollback - ${eligibilityResult.reason}` + ); + } + } + + // Only include products that have at least one eligible variant + if (eligibleVariants.length === 0) { + await this.logger.warning( + `Skipping product "${product.title}" for rollback - no variants with valid compare-at prices` + ); + skippedProductCount++; + continue; + } + + // Add product with only eligible variants + eligibleProducts.push({ + ...product, + variants: eligibleVariants, + }); + } + + // Log validation summary + if (skippedProductCount > 0) { + await this.logger.warning( + `Skipped ${skippedProductCount} products during rollback validation` + ); + } + + await this.logger.info( + `Rollback validation completed: ${eligibleProducts.length} products eligible (${eligibleVariantsCount}/${totalVariantsProcessed} variants eligible)` + ); + + return eligibleProducts; + } + + /** + * Update a single product variant price for rollback operation + * Sets the main price to the compare-at price and removes the compare-at price + * @param {Object} variant - Variant to rollback + * @param {string} variant.id - Variant ID + * @param {number} variant.price - Current price + * @param {number} variant.compareAtPrice - Compare-at price to use as new price + * @param {string} productId - Product ID that contains this variant + * @returns {Promise} 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. + + + diff --git a/src/tui/TuiApplication.jsx b/src/tui/TuiApplication.jsx index a68d7cb..9fc8598 100644 --- a/src/tui/TuiApplication.jsx +++ b/src/tui/TuiApplication.jsx @@ -1,8 +1,8 @@ const React = require("react"); const { Box, Text } = require("ink"); -const AppProvider = require("./providers/AppProvider"); -const Router = require("./components/Router"); -const StatusBar = require("./components/StatusBar"); +const AppProvider = require("./providers/AppProvider.jsx"); +const Router = require("./components/Router.jsx"); +const StatusBar = require("./components/StatusBar.jsx"); /** * Main TUI Application Component @@ -15,7 +15,7 @@ const TuiApplication = () => { null, React.createElement( Box, - { flexDirection: "column", height: "100%" }, + { flexDirection: "column" }, React.createElement(StatusBar), React.createElement(Router) ) diff --git a/src/tui/components/Router.jsx b/src/tui/components/Router.jsx index 93fcc8e..ab6faae 100644 --- a/src/tui/components/Router.jsx +++ b/src/tui/components/Router.jsx @@ -1,14 +1,14 @@ const React = require("react"); const { Box, Text } = require("ink"); -const { useAppState } = require("../providers/AppProvider"); +const { useAppState } = require("../providers/AppProvider.jsx"); -// Import screen components (will be created in later tasks) -// const MainMenuScreen = require('./screens/MainMenuScreen'); -// const ConfigurationScreen = require('./screens/ConfigurationScreen'); -// const OperationScreen = require('./screens/OperationScreen'); -// const SchedulingScreen = require('./screens/SchedulingScreen'); -// const LogViewerScreen = require('./screens/LogViewerScreen'); -// const TagAnalysisScreen = require('./screens/TagAnalysisScreen'); +// Import screen components +const MainMenuScreen = require("./screens/MainMenuScreen.jsx"); +const ConfigurationScreen = require("./screens/ConfigurationScreen.jsx"); +const OperationScreen = require("./screens/OperationScreen.jsx"); +const SchedulingScreen = require("./screens/SchedulingScreen.jsx"); +const LogViewerScreen = require("./screens/LogViewerScreen.jsx"); +const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx"); /** * Router Component @@ -18,44 +18,14 @@ const { useAppState } = require("../providers/AppProvider"); const Router = () => { const { appState } = useAppState(); - // Temporary placeholder screens until actual screens are implemented + // Screen components mapping const screens = { - "main-menu": () => - React.createElement( - Box, - { padding: 2 }, - React.createElement(Text, null, "Main Menu Screen - Coming Soon") - ), - configuration: () => - React.createElement( - Box, - { padding: 2 }, - React.createElement(Text, null, "Configuration Screen - Coming Soon") - ), - operation: () => - React.createElement( - Box, - { padding: 2 }, - React.createElement(Text, null, "Operation Screen - Coming Soon") - ), - scheduling: () => - React.createElement( - Box, - { padding: 2 }, - React.createElement(Text, null, "Scheduling Screen - Coming Soon") - ), - logs: () => - React.createElement( - Box, - { padding: 2 }, - React.createElement(Text, null, "Log Viewer Screen - Coming Soon") - ), - "tag-analysis": () => - React.createElement( - Box, - { padding: 2 }, - React.createElement(Text, null, "Tag Analysis Screen - Coming Soon") - ), + "main-menu": MainMenuScreen, + configuration: ConfigurationScreen, + operation: OperationScreen, + scheduling: SchedulingScreen, + logs: LogViewerScreen, + "tag-analysis": TagAnalysisScreen, }; // Get the current screen component diff --git a/src/tui/components/StatusBar.jsx b/src/tui/components/StatusBar.jsx index cb37771..842fa98 100644 --- a/src/tui/components/StatusBar.jsx +++ b/src/tui/components/StatusBar.jsx @@ -1,6 +1,6 @@ const React = require("react"); const { Box, Text } = require("ink"); -const { useAppState } = require("../providers/AppProvider"); +const { useAppState } = require("../providers/AppProvider.jsx"); /** * StatusBar Component diff --git a/src/tui/components/screens/ConfigurationScreen.jsx b/src/tui/components/screens/ConfigurationScreen.jsx new file mode 100644 index 0000000..c620c84 --- /dev/null +++ b/src/tui/components/screens/ConfigurationScreen.jsx @@ -0,0 +1,530 @@ +const React = require("react"); +const { Box, Text, useInput, useApp } = require("ink"); +const { useAppState } = require("../../providers/AppProvider.jsx"); +const TextInput = require("ink-text-input").default; + +/** + * Configuration Screen Component + * Form-based interface for setting up Shopify credentials and operation parameters + * Requirements: 5.2, 6.1, 6.2, 6.3 + */ +const ConfigurationScreen = () => { + const { appState, updateConfiguration, navigateBack, updateUIState } = + useAppState(); + const { exit } = useApp(); + + // Form fields configuration + const formFields = [ + { + id: "shopDomain", + label: "Shopify Shop Domain", + placeholder: "your-store.myshopify.com", + description: "Your Shopify store domain (without https://)", + validator: (value) => { + if (!value || value.trim() === "") return "Domain is required"; + if (!value.includes(".myshopify.com") && !value.includes(".")) { + return "Must be a valid Shopify domain"; + } + return null; + }, + }, + { + id: "accessToken", + label: "Shopify Access Token", + placeholder: "shpat_your_access_token_here", + description: "Your Shopify Admin API access token", + secret: true, + validator: (value) => { + if (!value || value.trim() === "") return "Access token is required"; + if (value.length < 10) return "Token appears to be too short"; + return null; + }, + }, + { + id: "targetTag", + label: "Target Product Tag", + placeholder: "sale", + description: "Products with this tag will be updated", + validator: (value) => { + if (!value || value.trim() === "") return "Target tag is required"; + return null; + }, + }, + { + id: "priceAdjustment", + label: "Price Adjustment Percentage", + placeholder: "10", + description: + "Percentage to adjust prices (positive for increase, negative for decrease)", + validator: (value) => { + if (!value || value.trim() === "") return "Percentage is required"; + const num = parseFloat(value); + if (isNaN(num)) return "Must be a valid number"; + return null; + }, + }, + { + id: "operationMode", + label: "Operation Mode", + placeholder: "update", + description: + "Choose between updating prices or rolling back to compare-at prices", + type: "select", + options: [ + { value: "update", label: "Update Prices" }, + { value: "rollback", label: "Rollback Prices" }, + ], + }, + ]; + + // State for form inputs + const [formValues, setFormValues] = React.useState( + formFields.reduce((acc, field) => { + acc[field.id] = appState.configuration[field.id] || ""; + return acc; + }, {}) + ); + + const [errors, setErrors] = React.useState({}); + const [focusedField, setFocusedField] = React.useState(0); + const [showValidation, setShowValidation] = React.useState(false); + + // Validate all fields + const validateForm = () => { + const newErrors = {}; + let isValid = true; + + formFields.forEach((field) => { + const error = field.validator(formValues[field.id]); + if (error) { + newErrors[field.id] = error; + isValid = false; + } + }); + + setErrors(newErrors); + return isValid; + }; + + // Handle keyboard input + useInput((input, key) => { + if (key.escape) { + // Go back to main menu + navigateBack(); + } else if (key.tab || (key.tab && key.shift)) { + // Navigate between fields + if (key.shift) { + // Shift+Tab - previous field + setFocusedField((prev) => + prev > 0 ? prev - 1 : formFields.length - 1 + ); + } else { + // Tab - next field + setFocusedField((prev) => + prev < formFields.length - 1 ? prev + 1 : 0 + ); + } + } else if (key.return || key.enter) { + // Handle Enter key + if (focusedField === formFields.length - 1) { + // Last field (Save button) - save configuration + handleSave(); + } else { + // Move to next field + setFocusedField((prev) => + prev < formFields.length - 1 ? prev + 1 : 0 + ); + } + } else if (key.upArrow) { + // Navigate up + setFocusedField((prev) => (prev > 0 ? prev - 1 : formFields.length - 1)); + } else if (key.downArrow) { + // Navigate down + setFocusedField((prev) => (prev < formFields.length - 1 ? prev + 1 : 0)); + } + }); + + // Handle input changes + const handleInputChange = (fieldId, value) => { + setFormValues((prev) => ({ + ...prev, + [fieldId]: value, + })); + + // Clear error when user starts typing + if (errors[fieldId]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[fieldId]; + return newErrors; + }); + } + }; + + // Handle save configuration + const handleSave = () => { + if (validateForm()) { + // Convert price adjustment to number + const config = { + ...formValues, + priceAdjustment: parseFloat(formValues.priceAdjustment), + isValid: true, + lastTested: null, + }; + + updateConfiguration(config); + + // Save to environment variables + saveToEnvironment(config); + + navigateBack(); + } else { + // Show validation errors + setShowValidation(true); + } + }; + + // Save configuration to environment variables + const saveToEnvironment = (config) => { + try { + const fs = require("fs"); + const path = require("path"); + const envPath = path.resolve(__dirname, "../../../.env"); + + let envContent = ""; + try { + envContent = fs.readFileSync(envPath, "utf8"); + } catch (err) { + // If file doesn't exist, create it + envContent = ""; + } + + // Update or add each configuration value + const envVars = { + SHOPIFY_SHOP_DOMAIN: config.shopDomain, + SHOPIFY_ACCESS_TOKEN: config.accessToken, + TARGET_TAG: config.targetTag, + PRICE_ADJUSTMENT_PERCENTAGE: config.priceAdjustment, + OPERATION_MODE: config.operationMode, + }; + + for (const [key, value] of Object.entries(envVars)) { + const regex = new RegExp(`^${key}=.*`, "m"); + const line = `${key}=${value}`; + + if (envContent.match(regex)) { + envContent = envContent.replace(regex, line); + } else { + envContent += `\n${line}`; + } + } + + fs.writeFileSync(envPath, envContent); + } catch (error) { + console.error("Failed to save configuration to environment:", error); + } + }; + + // Handle test connection + const handleTestConnection = async () => { + // Validate required fields first + const requiredFields = ["shopDomain", "accessToken"]; + const tempErrors = {}; + let hasError = false; + + requiredFields.forEach((fieldId) => { + const field = formFields.find((f) => f.id === fieldId); + const error = field.validator(formValues[fieldId]); + if (error) { + tempErrors[fieldId] = error; + hasError = true; + } + }); + + if (hasError) { + setErrors(tempErrors); + setShowValidation(true); + return; + } + + // Update configuration temporarily for testing + updateConfiguration({ + ...formValues, + priceAdjustment: parseFloat(formValues.priceAdjustment), + isValid: false, + }); + + // Test connection (this would integrate with the actual service) + // For now, we'll simulate the test + setFocusedField(formFields.length); // Show loading state + await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate API call + + // Simulate test result + const testSuccess = Math.random() > 0.2; // 80% success rate for demo + updateConfiguration({ + ...formValues, + priceAdjustment: parseFloat(formValues.priceAdjustment), + isValid: testSuccess, + lastTested: new Date(), + }); + + setFocusedField(formFields.length - 1); // Return to save button + }; + + return React.createElement( + Box, + { flexDirection: "column", padding: 1 }, + // Header + React.createElement( + Box, + { flexDirection: "column", marginBottom: 1 }, + React.createElement( + Text, + { bold: true, color: "cyan" }, + "⚙️ Configuration" + ), + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + "Set up your Shopify credentials and operation parameters" + ) + ), + + // Form fields + React.createElement( + Box, + { flexDirection: "column", marginBottom: 2 }, + formFields.map((field, index) => { + const isFocused = focusedField === index; + const hasError = errors[field.id]; + const currentValue = formValues[field.id]; + + return React.createElement( + Box, + { + key: field.id, + borderStyle: "single", + borderColor: hasError ? "red" : isFocused ? "blue" : "gray", + paddingX: 1, + paddingY: 1, + marginBottom: 1, + flexDirection: "column", + }, + React.createElement( + Box, + { flexDirection: "row", alignItems: "center", marginBottom: 1 }, + React.createElement( + Text, + { + bold: true, + color: isFocused ? "blue" : "white", + }, + `${field.label}:` + ), + React.createElement( + Box, + { flexGrow: 1 }, + React.createElement( + Text, + { color: "gray" }, + ` ${field.description}` + ) + ) + ), + React.createElement( + Box, + { flexDirection: "row" }, + field.type === "select" + ? React.createElement( + Box, + { flexDirection: "column" }, + field.options.map((option, optIndex) => + React.createElement( + Box, + { + key: optIndex, + borderStyle: + formValues[field.id] === option.value + ? "single" + : "none", + borderColor: "blue", + paddingX: 2, + paddingY: 0.5, + marginBottom: 0.5, + backgroundColor: + formValues[field.id] === option.value + ? "blue" + : undefined, + }, + React.createElement( + Text, + { + color: + formValues[field.id] === option.value + ? "white" + : "gray", + }, + option.label + ) + ) + ) + ) + : React.createElement(TextInput, { + value: currentValue, + placeholder: field.placeholder, + mask: field.secret ? "*" : null, + showCursor: isFocused, + focus: isFocused, + onChange: (value) => handleInputChange(field.id, value), + style: { + color: hasError ? "red" : isFocused ? "blue" : "white", + bold: isFocused, + }, + }) + ), + hasError && + React.createElement( + Text, + { color: "red", italic: true }, + ` Error: ${errors[field.id]}` + ) + ); + }) + ), + + // Action buttons + React.createElement( + Box, + { flexDirection: "row", justifyContent: "space-between" }, + React.createElement( + Box, + { flexDirection: "column", width: "48%" }, + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "gray", + paddingX: 2, + paddingY: 1, + alignItems: "center", + backgroundColor: + focusedField === formFields.length ? "yellow" : undefined, + }, + React.createElement( + Text, + { + color: focusedField === formFields.length ? "black" : "white", + bold: true, + }, + focusedField === formFields.length + ? "Testing..." + : "Test Connection" + ) + ), + React.createElement( + Text, + { color: "gray", italic: true, marginTop: 0.5 }, + "Verify your credentials" + ) + ), + React.createElement( + Box, + { flexDirection: "column", width: "48%" }, + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "green", + paddingX: 2, + paddingY: 1, + alignItems: "center", + backgroundColor: + focusedField === formFields.length - 1 ? "green" : undefined, + }, + React.createElement( + Text, + { + color: "white", + bold: true, + }, + "Save & Exit" + ) + ), + React.createElement( + Text, + { color: "gray", italic: true, marginTop: 0.5 }, + "Save configuration and return to menu" + ) + ) + ), + + // Configuration status + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 2, + borderTopStyle: "single", + borderColor: "gray", + paddingTop: 2, + }, + React.createElement( + Text, + { + color: appState.configuration.isValid ? "green" : "red", + bold: true, + }, + `Configuration Status: ${ + appState.configuration.isValid ? "✓ Valid" : "⚠ Incomplete" + }` + ), + appState.configuration.lastTested && + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + `Last tested: ${appState.configuration.lastTested.toLocaleString()}` + ) + ), + + // Instructions + React.createElement( + Box, + { flexDirection: "column", marginTop: 2 }, + React.createElement(Text, { color: "gray" }, "Navigation:"), + React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate fields"), + React.createElement( + Text, + { color: "gray" }, + " Tab/Shift+Tab - Next/Previous field" + ), + React.createElement(Text, { color: "gray" }, " Enter - Save/Next field"), + React.createElement(Text, { color: "gray" }, " Esc - Back to menu") + ), + + // Validation message + showValidation && + Object.keys(errors).length > 0 && + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 2, + padding: 1, + borderStyle: "single", + borderColor: "red", + }, + React.createElement( + Text, + { color: "red", bold: true }, + "Validation Errors:" + ), + React.createElement( + Text, + { color: "red" }, + "Please fix the errors before saving." + ) + ) + ); +}; + +module.exports = ConfigurationScreen; diff --git a/src/tui/components/screens/LogViewerScreen.jsx b/src/tui/components/screens/LogViewerScreen.jsx new file mode 100644 index 0000000..c4e8c1e --- /dev/null +++ b/src/tui/components/screens/LogViewerScreen.jsx @@ -0,0 +1,411 @@ +const React = require("react"); +const { Box, Text, useInput, useApp } = require("ink"); +const { useAppState } = require("../../providers/AppProvider.jsx"); +const SelectInput = require("ink-select-input").default; + +/** + * Log Viewer Screen Component + * Displays application logs with filtering and navigation capabilities + * Requirements: 7.5, 7.6 + */ +const LogViewerScreen = () => { + const { appState, navigateBack } = useAppState(); + const { exit } = useApp(); + + // Mock log data (in a real implementation, this would read from log files) + const mockLogs = [ + { + timestamp: new Date(Date.now() - 1000 * 60 * 5), + level: "INFO", + message: "Application started", + details: "Shopify Price Updater v1.0.0", + }, + { + timestamp: new Date(Date.now() - 1000 * 60 * 4), + level: "INFO", + message: "Configuration loaded", + details: "Target tag: 'sale', Adjustment: 10%", + }, + { + timestamp: new Date(Date.now() - 1000 * 60 * 3), + level: "INFO", + message: "Testing Shopify connection", + details: "Connecting to store...", + }, + { + timestamp: new Date(Date.now() - 1000 * 60 * 2), + level: "SUCCESS", + message: "Connection successful", + details: "API version: 2023-01", + }, + { + timestamp: new Date(Date.now() - 1000 * 60 * 1), + level: "INFO", + message: "Fetching products", + details: "Query: tag:sale", + }, + { + timestamp: new Date(Date.now() - 1000 * 60 * 1), + level: "INFO", + message: "Products found", + details: "Found 15 products with tag 'sale'", + }, + { + timestamp: new Date(Date.now() - 1000 * 30), + level: "INFO", + message: "Starting price updates", + details: "Processing 15 products, 42 variants total", + }, + { + timestamp: new Date(Date.now() - 1000 * 25), + level: "INFO", + message: "Updating product", + details: "Product: 'Summer T-Shirt' - New price: $22.00", + }, + { + timestamp: new Date(Date.now() - 1000 * 20), + level: "INFO", + message: "Updating product", + details: "Product: 'Winter Jacket' - New price: $110.00", + }, + { + timestamp: new Date(Date.now() - 1000 * 15), + level: "WARNING", + message: "Price update failed", + details: "Product: 'Limited Edition Sneaker' - Insufficient permissions", + }, + { + timestamp: new Date(Date.now() - 1000 * 10), + level: "INFO", + message: "Updating product", + details: "Product: 'Casual Jeans' - New price: $45.00", + }, + { + timestamp: new Date(Date.now() - 1000 * 5), + level: "SUCCESS", + message: "Operation completed", + details: "Updated 40 of 42 variants (95.2% success rate)", + }, + ]; + + // State for log viewing + const [logs, setLogs] = React.useState(mockLogs); + const [filteredLogs, setFilteredLogs] = React.useState(mockLogs); + const [filterLevel, setFilterLevel] = React.useState("ALL"); + const [selectedLog, setSelectedLog] = React.useState(null); + const [showDetails, setShowDetails] = React.useState(false); + const [scrollPosition, setScrollPosition] = React.useState(0); + const [maxScroll, setMaxScroll] = React.useState(0); + + // Filter options + const filterOptions = [ + { value: "ALL", label: "All Levels" }, + { value: "ERROR", label: "Errors" }, + { value: "WARNING", label: "Warnings" }, + { value: "INFO", label: "Info" }, + { value: "SUCCESS", label: "Success" }, + ]; + + // Filter logs based on selected level + const filterLogs = () => { + if (filterLevel === "ALL") { + setFilteredLogs(logs); + } else { + setFilteredLogs(logs.filter((log) => log.level === filterLevel)); + } + }; + + // Handle keyboard input + useInput((input, key) => { + if (key.escape) { + // Go back to main menu + navigateBack(); + } else if (key.upArrow) { + // Navigate up in log list + if (selectedLog > 0) { + setSelectedLog(selectedLog - 1); + setShowDetails(false); + } + } else if (key.downArrow) { + // Navigate down in log list + if (selectedLog < filteredLogs.length - 1) { + setSelectedLog(selectedLog + 1); + setShowDetails(false); + } + } else if (key.return || key.enter) { + // Toggle log details + if (selectedLog !== null) { + setShowDetails(!showDetails); + } + } else if (key.r) { + // Refresh logs + setLogs(mockLogs); + setFilteredLogs(mockLogs); + setSelectedLog(null); + setShowDetails(false); + } + }); + + // Handle filter change + const handleFilterChange = (option) => { + setFilterLevel(option.value); + }; + + // Get log level color + const getLogLevelColor = (level) => { + switch (level) { + case "ERROR": + return "red"; + case "WARNING": + return "yellow"; + case "INFO": + return "blue"; + case "SUCCESS": + return "green"; + default: + return "white"; + } + }; + + // Format timestamp + const formatTimestamp = (date) => { + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }; + + return React.createElement( + Box, + { flexDirection: "column", padding: 2, flexGrow: 1 }, + // Header + React.createElement( + Box, + { flexDirection: "column", marginBottom: 2 }, + React.createElement(Text, { bold: true, color: "cyan" }, "📋 Log Viewer"), + React.createElement( + Text, + { color: "gray" }, + "View application logs and operation history" + ) + ), + + // Filter controls + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "blue", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "blue" }, + "Log Filters:" + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement(Text, { color: "white" }, "Level: "), + React.createElement(SelectInput, { + items: filterOptions, + selectedIndex: filterOptions.findIndex( + (opt) => opt.value === filterLevel + ), + onSelect: handleFilterChange, + itemComponent: ({ label, isSelected }) => + React.createElement( + Text, + { + color: isSelected ? "blue" : "white", + bold: isSelected, + }, + label + ), + }) + ), + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + `Showing ${filteredLogs.length} of ${logs.length} log entries` + ) + ) + ), + + // Log list + React.createElement( + Box, + { + flexGrow: 1, + borderStyle: "single", + borderColor: "gray", + flexDirection: "column", + }, + filteredLogs.map((log, index) => { + const isSelected = selectedLog === index; + const isHighlighted = isSelected && !showDetails; + + return React.createElement( + Box, + { + key: index, + borderStyle: "single", + borderColor: isSelected ? "blue" : "transparent", + paddingX: 1, + paddingY: 0.5, + backgroundColor: isHighlighted ? "blue" : undefined, + }, + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { + color: getLogLevelColor(log.level), + bold: true, + width: 8, + }, + log.level + ), + React.createElement( + Text, + { + color: isHighlighted ? "white" : "gray", + width: 8, + }, + formatTimestamp(log.timestamp) + ), + React.createElement( + Text, + { + color: isHighlighted ? "white" : "white", + flexGrow: 1, + }, + log.message + ) + ) + ); + }) + ), + + // Log details (when selected) + showDetails && + selectedLog !== null && + filteredLogs[selectedLog] && + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "green", + paddingX: 1, + paddingY: 1, + marginTop: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "green" }, + "Log Details:" + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { color: "white", bold: true }, + "Level: " + ), + React.createElement( + Text, + { color: getLogLevelColor(filteredLogs[selectedLog].level) }, + filteredLogs[selectedLog].level + ) + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement(Text, { color: "white", bold: true }, "Time: "), + React.createElement( + Text, + { color: "gray" }, + filteredLogs[selectedLog].timestamp.toLocaleString() + ) + ), + React.createElement( + Box, + { flexDirection: "column", marginTop: 1 }, + React.createElement( + Text, + { color: "white", bold: true }, + "Message:" + ), + React.createElement( + Text, + { color: "white" }, + filteredLogs[selectedLog].message + ) + ), + React.createElement( + Box, + { flexDirection: "column", marginTop: 1 }, + React.createElement( + Text, + { color: "white", bold: true }, + "Details:" + ), + React.createElement( + Text, + { color: "gray", italic: true }, + filteredLogs[selectedLog].details + ) + ) + ) + ), + + // Instructions + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 2, + borderTopStyle: "single", + borderColor: "gray", + paddingTop: 2, + }, + React.createElement(Text, { color: "gray" }, "Controls:"), + React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate logs"), + React.createElement(Text, { color: "gray" }, " Enter - View details"), + React.createElement(Text, { color: "gray" }, " R - Refresh logs"), + React.createElement(Text, { color: "gray" }, " Esc - Back to menu") + ), + + // Status bar + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "gray", + paddingX: 1, + paddingY: 0.5, + marginTop: 1, + }, + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + `Selected: ${ + selectedLog !== null ? `Log #${selectedLog + 1}` : "None" + } | Details: ${showDetails ? "Visible" : "Hidden"}` + ) + ) + ); +}; + +module.exports = LogViewerScreen; diff --git a/src/tui/components/screens/MainMenuScreen.jsx b/src/tui/components/screens/MainMenuScreen.jsx new file mode 100644 index 0000000..0374e65 --- /dev/null +++ b/src/tui/components/screens/MainMenuScreen.jsx @@ -0,0 +1,203 @@ +const React = require("react"); +const { Box, Text, useInput } = require("ink"); +const { useAppState } = require("../../providers/AppProvider.jsx"); + +/** + * Main Menu Screen Component + * Provides the primary navigation interface for the application + * Requirements: 5.1, 5.3, 7.1 + */ +const MainMenuScreen = () => { + const { appState, navigateTo, updateUIState } = useAppState(); + + // Menu items configuration + const menuItems = [ + { + id: "configuration", + label: "Configuration", + description: "Set up Shopify credentials and operation parameters", + }, + { + id: "operation", + label: "Run Operation", + description: "Execute price update or rollback operation", + }, + { + id: "scheduling", + label: "Scheduling", + description: "Configure scheduled operations", + }, + { + id: "tag-analysis", + label: "Tag Analysis", + description: "Explore product tags in your store", + }, + { + id: "logs", + label: "View Logs", + description: "Browse operation logs and history", + }, + { id: "exit", label: "Exit", description: "Quit the application" }, + ]; + + // Handle keyboard input + useInput((input, key) => { + if (key.upArrow) { + // Navigate up in menu + const newIndex = Math.max(0, appState.uiState.selectedMenuIndex - 1); + updateUIState({ selectedMenuIndex: newIndex }); + } else if (key.downArrow) { + // Navigate down in menu + const newIndex = Math.min( + menuItems.length - 1, + appState.uiState.selectedMenuIndex + 1 + ); + updateUIState({ selectedMenuIndex: newIndex }); + } else if (key.return || key.enter || input === " ") { + // Select menu item + const selectedItem = menuItems[appState.uiState.selectedMenuIndex]; + if (selectedItem.id === "exit") { + // Exit the application + process.exit(0); + } else { + // Navigate to selected screen + navigateTo(selectedItem.id); + } + } else if (input === "q" || input === "Q") { + // Quick exit with 'q' + process.exit(0); + } + }); + + return React.createElement( + Box, + { flexDirection: "column", padding: 1 }, + // Header + React.createElement( + Box, + { flexDirection: "column", marginBottom: 1 }, + React.createElement( + Text, + { bold: true, color: "cyan" }, + "🛍️ Shopify Price Updater" + ), + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + "Terminal User Interface" + ) + ), + + // Welcome message + React.createElement( + Box, + { flexDirection: "column", marginBottom: 1 }, + React.createElement( + Text, + { color: "green", fontSize: "small" }, + "Shopify Price Updater TUI" + ), + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + "Arrow keys: Navigate | Enter: Select" + ) + ), + + // Menu items + React.createElement( + Box, + { flexDirection: "column" }, + menuItems.map((item, index) => { + const isSelected = index === appState.uiState.selectedMenuIndex; + const isConfigured = appState.configuration.isValid; + + return React.createElement( + Box, + { + key: item.id, + borderStyle: "single", + borderColor: isSelected ? "blue" : "gray", + paddingX: 1, + paddingY: 1, + marginBottom: 1, + flexDirection: "column", + }, + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { + bold: isSelected, + color: isSelected ? "blue" : "white", + }, + `${isSelected ? "▶" : " "} ${item.label}` + ), + // Configuration status indicator + item.id === "operation" && + !isConfigured && + React.createElement( + Box, + { marginLeft: 2 }, + React.createElement(Text, { color: "red" }, "⚠️ Not Configured") + ) + ), + React.createElement( + Text, + { + color: isSelected ? "cyan" : "gray", + italic: true, + }, + ` ${item.description}` + ) + ); + }) + ), + + // Footer with instructions + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 3, + borderTopStyle: "single", + borderColor: "gray", + paddingTop: 2, + }, + React.createElement(Text, { color: "gray" }, "Navigation:"), + React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate menu"), + React.createElement( + Text, + { color: "gray" }, + " Enter/Space - Select item" + ), + React.createElement(Text, { color: "gray" }, " q - Quick exit"), + React.createElement( + Text, + { color: "gray" }, + " Esc - Back (when available)" + ) + ), + + // Configuration status + React.createElement( + Box, + { flexDirection: "row", justifyContent: "space-between", marginTop: 2 }, + React.createElement( + Text, + { color: appState.configuration.isValid ? "green" : "red" }, + `Configuration: ${ + appState.configuration.isValid ? "✓ Complete" : "⚠ Incomplete" + }` + ), + React.createElement( + Text, + { color: "gray" }, + `Mode: ${appState.configuration.operationMode.toUpperCase()}` + ) + ) + ); +}; + +module.exports = MainMenuScreen; diff --git a/src/tui/components/screens/OperationScreen.jsx b/src/tui/components/screens/OperationScreen.jsx new file mode 100644 index 0000000..130e113 --- /dev/null +++ b/src/tui/components/screens/OperationScreen.jsx @@ -0,0 +1,484 @@ +const React = require("react"); +const { Box, Text, useInput, useApp } = require("ink"); +const { useAppState } = require("../../providers/AppProvider.jsx"); +const Spinner = require("ink-spinner").default; +const ProgressBar = require("../common/ProgressBar.jsx"); + +/** + * Operation Screen Component + * Handles execution of price update or rollback operations with real-time progress monitoring + * Requirements: 7.2, 8.1, 8.2, 8.3, 8.4 + */ +const OperationScreen = () => { + const { appState, navigateBack, updateOperationState } = useAppState(); + const { exit } = useApp(); + + // Operation states + const [operationStatus, setOperationStatus] = React.useState("ready"); // ready, running, completed, cancelled, error + const [progress, setProgress] = React.useState(0); + const [currentStep, setCurrentStep] = React.useState(""); + const [productsProcessed, setProductsProcessed] = React.useState(0); + const [totalProducts, setTotalProducts] = React.useState(0); + const [errors, setErrors] = React.useState([]); + const [results, setResults] = React.useState(null); + + // Simulate operation execution (would integrate with actual services) + const executeOperation = async () => { + if (!appState.configuration.isValid) { + setOperationStatus("error"); + setErrors([ + "Configuration is not valid. Please configure your settings first.", + ]); + return; + } + + setOperationStatus("running"); + setProgress(0); + setErrors([]); + setResults(null); + + try { + // Simulate fetching products + setCurrentStep("Fetching products..."); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Simulate finding products + const mockProducts = [ + { id: "1", title: "Product A", variants: [{ id: "v1", price: 19.99 }] }, + { id: "2", title: "Product B", variants: [{ id: "v2", price: 29.99 }] }, + { id: "3", title: "Product C", variants: [{ id: "v3", price: 39.99 }] }, + { id: "4", title: "Product D", variants: [{ id: "v4", price: 49.99 }] }, + { id: "5", title: "Product E", variants: [{ id: "v5", price: 59.99 }] }, + ]; + + setTotalProducts(mockProducts.length); + setProductsProcessed(0); + + // Simulate processing each product + for (let i = 0; i < mockProducts.length; i++) { + const product = mockProducts[i]; + + // Simulate API call delay + await new Promise((resolve) => setTimeout(resolve, 800)); + + // Update progress + const newProgress = ((i + 1) / mockProducts.length) * 100; + setProgress(newProgress); + setProductsProcessed(i + 1); + setCurrentStep(`Processing: ${product.title}`); + + // Simulate occasional errors + if (Math.random() < 0.1 && i > 0) { + // 10% chance of error after first product + const error = { + productTitle: product.title, + productId: product.id, + errorMessage: "Simulated API error", + }; + setErrors((prev) => [...prev, error]); + } + } + + // Complete operation + setOperationStatus("completed"); + setCurrentStep("Operation completed!"); + setResults({ + totalProducts: mockProducts.length, + totalVariants: mockProducts.reduce( + (sum, p) => sum + p.variants.length, + 0 + ), + successfulUpdates: mockProducts.length - errors.length, + failedUpdates: errors.length, + errors: errors, + }); + } catch (error) { + setOperationStatus("error"); + setErrors([ + { productTitle: "System Error", errorMessage: error.message }, + ]); + } + }; + + // Handle keyboard input + useInput((input, key) => { + if (key.escape) { + // Cancel operation if running + if (operationStatus === "running") { + setOperationStatus("cancelled"); + setCurrentStep("Operation cancelled by user"); + } else { + // Go back to main menu + navigateBack(); + } + } else if ((key.return || key.enter) && operationStatus === "ready") { + // Start operation + executeOperation(); + } + }); + + // Update operation state in context + React.useEffect(() => { + updateOperationState({ + status: operationStatus, + progress: progress, + currentStep: currentStep, + errors: errors, + results: results, + }); + }, [operationStatus, progress, currentStep, errors, results]); + + return React.createElement( + Box, + { flexDirection: "column", padding: 1 }, + // Header + React.createElement( + Box, + { flexDirection: "column", marginBottom: 1 }, + React.createElement( + Text, + { + bold: true, + color: operationStatus === "running" ? "yellow" : "cyan", + }, + `🚀 ${ + appState.configuration.operationMode === "rollback" + ? "Price Rollback" + : "Price Update" + } Operation` + ), + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + `Target tag: ${appState.configuration.targetTag || "Not set"}` + ) + ), + + // Configuration summary + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "blue", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "blue" }, + "Configuration Summary:" + ), + React.createElement( + Text, + null, + ` Store: ${appState.configuration.shopDomain || "Not set"}` + ), + React.createElement( + Text, + null, + ` Target Tag: ${appState.configuration.targetTag || "Not set"}` + ), + React.createElement( + Text, + null, + ` Mode: ${appState.configuration.operationMode.toUpperCase()}` + ), + appState.configuration.operationMode === "update" && + React.createElement( + Text, + null, + ` Adjustment: ${appState.configuration.priceAdjustment || 0}%` + ) + ) + ), + + // Operation status + React.createElement( + Box, + { flexDirection: "column", marginBottom: 3 }, + React.createElement( + Box, + { flexDirection: "row", alignItems: "center", marginBottom: 1 }, + React.createElement( + Text, + { + bold: true, + color: getStatusColor(operationStatus), + }, + getStatusIcon(operationStatus) + " " + getStatusText(operationStatus) + ), + operationStatus === "running" && + React.createElement(Spinner, { type: "dots" }) + ), + currentStep && + React.createElement( + Text, + { color: "gray", italic: true }, + `Current step: ${currentStep}` + ) + ), + + // Progress bar + operationStatus !== "ready" && + React.createElement( + Box, + { flexDirection: "column", marginBottom: 3 }, + React.createElement(ProgressBar, { + value: progress, + max: 100, + label: "Operation Progress", + showPercentage: true, + showValue: false, + style: { + bar: { fg: "green" }, + empty: { fg: "gray" }, + label: { fg: "white", bold: true }, + percentage: { fg: "cyan" }, + }, + }), + React.createElement( + Box, + { flexDirection: "row", justifyContent: "space-between" }, + React.createElement( + Text, + { color: "gray" }, + `Products: ${productsProcessed}/${totalProducts}` + ), + React.createElement( + Text, + { color: "gray" }, + `${Math.round(progress)}%` + ) + ) + ), + + // Results (when operation is completed) + operationStatus === "completed" && + results && + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "green", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "green" }, + "Operation Results:" + ), + React.createElement( + Text, + null, + ` Total Products: ${results.totalProducts}` + ), + React.createElement( + Text, + null, + ` Total Variants: ${results.totalVariants}` + ), + React.createElement( + Text, + { color: "green" }, + ` Successful: ${results.successfulUpdates}` + ), + React.createElement( + Text, + { color: results.failedUpdates > 0 ? "yellow" : "green" }, + ` Failed: ${results.failedUpdates}` + ) + ) + ), + + // Errors (if any) + errors.length > 0 && + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "red", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "red" }, + "Errors Encountered:" + ), + errors + .slice(0, 3) + .map((error, index) => + React.createElement( + Box, + { key: index, flexDirection: "column" }, + React.createElement( + Text, + { color: "red" }, + ` • ${error.productTitle}: ${error.errorMessage}` + ) + ) + ), + errors.length > 3 && + React.createElement( + Text, + { color: "red", italic: true }, + ` ... and ${errors.length - 3} more errors` + ) + ) + ), + + // Action buttons + React.createElement( + Box, + { flexDirection: "row", justifyContent: "space-between", marginTop: 2 }, + React.createElement( + Box, + { flexDirection: "column", width: "48%" }, + React.createElement( + Box, + { + borderStyle: "single", + borderColor: operationStatus === "ready" ? "green" : "gray", + paddingX: 2, + paddingY: 1, + alignItems: "center", + backgroundColor: operationStatus === "ready" ? "green" : undefined, + }, + React.createElement( + Text, + { + color: operationStatus === "ready" ? "white" : "gray", + bold: true, + }, + operationStatus === "ready" + ? "Start Operation" + : "Operation Running" + ) + ), + React.createElement( + Text, + { color: "gray", italic: true, marginTop: 0.5 }, + operationStatus === "ready" + ? "Begin price update" + : "Processing products..." + ) + ), + React.createElement( + Box, + { flexDirection: "column", width: "48%" }, + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "red", + paddingX: 2, + paddingY: 1, + alignItems: "center", + backgroundColor: operationStatus === "running" ? "red" : undefined, + }, + React.createElement( + Text, + { + color: "white", + bold: true, + }, + "Cancel Operation" + ) + ), + React.createElement( + Text, + { color: "gray", italic: true, marginTop: 0.5 }, + operationStatus === "running" + ? "Stop current operation" + : "Not running" + ) + ) + ), + + // Instructions + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 2, + borderTopStyle: "single", + borderColor: "gray", + paddingTop: 2, + }, + React.createElement(Text, { color: "gray" }, "Controls:"), + React.createElement(Text, { color: "gray" }, " Enter - Start operation"), + React.createElement( + Text, + { color: "gray" }, + " Esc - Cancel/Back to menu" + ) + ) + ); +}; + +// Helper functions +function getStatusColor(status) { + switch (status) { + case "ready": + return "blue"; + case "running": + return "yellow"; + case "completed": + return "green"; + case "cancelled": + return "yellow"; + case "error": + return "red"; + default: + return "white"; + } +} + +function getStatusIcon(status) { + switch (status) { + case "ready": + return "⏳"; + case "running": + return "🔄"; + case "completed": + return "✅"; + case "cancelled": + return "⏹️"; + case "error": + return "❌"; + default: + return "❓"; + } +} + +function getStatusText(status) { + switch (status) { + case "ready": + return "Ready to Start"; + case "running": + return "Operation in Progress"; + case "completed": + return "Operation Completed"; + case "cancelled": + return "Operation Cancelled"; + case "error": + return "Operation Error"; + default: + return "Unknown Status"; + } +} + +module.exports = OperationScreen; diff --git a/src/tui/components/screens/SchedulingScreen.jsx b/src/tui/components/screens/SchedulingScreen.jsx new file mode 100644 index 0000000..f4b91ec --- /dev/null +++ b/src/tui/components/screens/SchedulingScreen.jsx @@ -0,0 +1,505 @@ +const React = require("react"); +const { Box, Text, useInput, useApp } = require("ink"); +const { useAppState } = require("../../providers/AppProvider.jsx"); +const TextInput = require("ink-text-input").default; + +/** + * Scheduling Screen Component + * Interface for configuring scheduled price update operations + * Requirements: 7.3, 7.4 + */ +const SchedulingScreen = () => { + const { appState, navigateBack, updateConfiguration } = useAppState(); + const { exit } = useApp(); + + // Form fields configuration + const formFields = [ + { + id: "scheduledTime", + label: "Scheduled Execution Time", + placeholder: "2023-12-25T15:30:00", + description: "When to run the operation (ISO 8601 format)", + validator: (value) => { + if (!value || value.trim() === "") return "Scheduled time is required"; + try { + const date = new Date(value); + if (isNaN(date.getTime())) return "Invalid date format"; + if (date <= new Date()) return "Scheduled time must be in the future"; + return null; + } catch (error) { + return "Invalid date format"; + } + }, + }, + { + id: "scheduleType", + label: "Schedule Type", + placeholder: "one-time", + description: "Type of schedule", + type: "select", + options: [ + { value: "one-time", label: "One-time" }, + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + ], + }, + { + id: "scheduleTime", + label: "Schedule Time", + placeholder: "15:30", + description: "Time of day for recurring schedules", + validator: (value) => { + if (!value || value.trim() === "") return "Time is required"; + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + if (!timeRegex.test(value)) return "Invalid time format (HH:MM)"; + return null; + }, + }, + ]; + + // State for form inputs + const [formValues, setFormValues] = React.useState( + formFields.reduce((acc, field) => { + acc[field.id] = appState.configuration[field.id] || ""; + return acc; + }, {}) + ); + + const [errors, setErrors] = React.useState({}); + const [focusedField, setFocusedField] = React.useState(0); + const [showValidation, setShowValidation] = React.useState(false); + const [nextRunTime, setNextRunTime] = React.useState(null); + + // Validate all fields + const validateForm = () => { + const newErrors = {}; + let isValid = true; + + formFields.forEach((field) => { + const error = field.validator(formValues[field.id]); + if (error) { + newErrors[field.id] = error; + isValid = false; + } + }); + + setErrors(newErrors); + return isValid; + }; + + // Calculate next run time + const calculateNextRunTime = () => { + if (!formValues.scheduledTime) return null; + + try { + const scheduledDate = new Date(formValues.scheduledTime); + const now = new Date(); + + // Format for display + const options = { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }; + + return scheduledDate.toLocaleString(undefined, options); + } catch (error) { + return null; + } + }; + + // Handle keyboard input + useInput((input, key) => { + if (key.escape) { + // Go back to main menu + navigateBack(); + } else if (key.tab || (key.tab && key.shift)) { + // Navigate between fields + if (key.shift) { + // Shift+Tab - previous field + setFocusedField((prev) => + prev > 0 ? prev - 1 : formFields.length - 1 + ); + } else { + // Tab - next field + setFocusedField((prev) => + prev < formFields.length - 1 ? prev + 1 : 0 + ); + } + } else if (key.return || key.enter) { + // Handle Enter key + if (focusedField === formFields.length - 1) { + // Last field (Save button) - save configuration + handleSave(); + } else { + // Move to next field + setFocusedField((prev) => + prev < formFields.length - 1 ? prev + 1 : 0 + ); + } + } else if (key.upArrow) { + // Navigate up + setFocusedField((prev) => (prev > 0 ? prev - 1 : formFields.length - 1)); + } else if (key.downArrow) { + // Navigate down + setFocusedField((prev) => (prev < formFields.length - 1 ? prev + 1 : 0)); + } + }); + + // Handle input changes + const handleInputChange = (fieldId, value) => { + setFormValues((prev) => ({ + ...prev, + [fieldId]: value, + })); + + // Clear error when user starts typing + if (errors[fieldId]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[fieldId]; + return newErrors; + }); + } + + // Update next run time when scheduled time changes + if (fieldId === "scheduledTime") { + setNextRunTime(calculateNextRunTime()); + } + }; + + // Handle save configuration + const handleSave = () => { + if (validateForm()) { + // Update configuration + const config = { + ...formValues, + isScheduled: true, + }; + + updateConfiguration(config); + navigateBack(); + } else { + // Show validation errors + setShowValidation(true); + } + }; + + // Handle test schedule + const handleTestSchedule = () => { + // Validate required fields + const requiredFields = ["scheduledTime"]; + const tempErrors = {}; + let hasError = false; + + requiredFields.forEach((fieldId) => { + const field = formFields.find((f) => f.id === fieldId); + const error = field.validator(formValues[fieldId]); + if (error) { + tempErrors[fieldId] = error; + hasError = true; + } + }); + + if (hasError) { + setErrors(tempErrors); + setShowValidation(true); + return; + } + + // Calculate and display next run time + setNextRunTime(calculateNextRunTime()); + }; + + return React.createElement( + Box, + { flexDirection: "column", padding: 2, flexGrow: 1 }, + // Header + React.createElement( + Box, + { flexDirection: "column", marginBottom: 2 }, + React.createElement(Text, { bold: true, color: "cyan" }, "⏰ Scheduling"), + React.createElement( + Text, + { color: "gray" }, + "Configure when to run price update operations" + ) + ), + + // Schedule information + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "blue", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "blue" }, + "Schedule Information:" + ), + React.createElement( + Text, + null, + ` Current Mode: ${appState.configuration.operationMode.toUpperCase()}` + ), + React.createElement( + Text, + null, + ` Target Tag: ${appState.configuration.targetTag || "Not set"}` + ), + React.createElement( + Text, + null, + ` Store: ${appState.configuration.shopDomain || "Not set"}` + ) + ) + ), + + // Form fields + React.createElement( + Box, + { flexDirection: "column", marginBottom: 2 }, + formFields.map((field, index) => { + const isFocused = focusedField === index; + const hasError = errors[field.id]; + const currentValue = formValues[field.id]; + + return React.createElement( + Box, + { + key: field.id, + borderStyle: "single", + borderColor: hasError ? "red" : isFocused ? "blue" : "gray", + paddingX: 1, + paddingY: 1, + marginBottom: 1, + flexDirection: "column", + }, + React.createElement( + Box, + { flexDirection: "row", alignItems: "center", marginBottom: 1 }, + React.createElement( + Text, + { + bold: true, + color: isFocused ? "blue" : "white", + }, + `${field.label}:` + ), + React.createElement( + Box, + { flexGrow: 1 }, + React.createElement( + Text, + { color: "gray" }, + ` ${field.description}` + ) + ) + ), + React.createElement( + Box, + { flexDirection: "row" }, + field.type === "select" + ? React.createElement( + Box, + { flexDirection: "column" }, + field.options.map((option, optIndex) => + React.createElement( + Box, + { + key: optIndex, + borderStyle: + formValues[field.id] === option.value + ? "single" + : "none", + borderColor: "blue", + paddingX: 2, + paddingY: 0.5, + marginBottom: 0.5, + backgroundColor: + formValues[field.id] === option.value + ? "blue" + : undefined, + }, + React.createElement( + Text, + { + color: + formValues[field.id] === option.value + ? "white" + : "gray", + }, + option.label + ) + ) + ) + ) + : React.createElement(TextInput, { + value: currentValue, + placeholder: field.placeholder, + mask: null, + showCursor: isFocused, + focus: isFocused, + onChange: (value) => handleInputChange(field.id, value), + style: { + color: hasError ? "red" : isFocused ? "blue" : "white", + bold: isFocused, + }, + }) + ), + hasError && + React.createElement( + Text, + { color: "red", italic: true }, + ` Error: ${errors[field.id]}` + ) + ); + }) + ), + + // Next run time display + nextRunTime && + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "green", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "green" }, + "Next Scheduled Run:" + ), + React.createElement(Text, { color: "green" }, ` ${nextRunTime}`) + ) + ), + + // Action buttons + React.createElement( + Box, + { flexDirection: "row", justifyContent: "space-between" }, + React.createElement( + Box, + { flexDirection: "column", width: "48%" }, + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "gray", + paddingX: 2, + paddingY: 1, + alignItems: "center", + backgroundColor: + focusedField === formFields.length ? "yellow" : undefined, + }, + React.createElement( + Text, + { + color: focusedField === formFields.length ? "black" : "white", + bold: true, + }, + focusedField === formFields.length ? "Testing..." : "Test Schedule" + ) + ), + React.createElement( + Text, + { color: "gray", italic: true, marginTop: 0.5 }, + "Calculate next run time" + ) + ), + React.createElement( + Box, + { flexDirection: "column", width: "48%" }, + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "green", + paddingX: 2, + paddingY: 1, + alignItems: "center", + backgroundColor: + focusedField === formFields.length - 1 ? "green" : undefined, + }, + React.createElement( + Text, + { + color: "white", + bold: true, + }, + "Save & Exit" + ) + ), + React.createElement( + Text, + { color: "gray", italic: true, marginTop: 0.5 }, + "Save schedule and return to menu" + ) + ) + ), + + // Instructions + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 2, + borderTopStyle: "single", + borderColor: "gray", + paddingTop: 2, + }, + React.createElement(Text, { color: "gray" }, "Navigation:"), + React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate fields"), + React.createElement( + Text, + { color: "gray" }, + " Tab/Shift+Tab - Next/Previous field" + ), + React.createElement(Text, { color: "gray" }, " Enter - Save/Next field"), + React.createElement(Text, { color: "gray" }, " Esc - Back to menu") + ), + + // Validation message + showValidation && + Object.keys(errors).length > 0 && + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 2, + padding: 1, + borderStyle: "single", + borderColor: "red", + }, + React.createElement( + Text, + { color: "red", bold: true }, + "Validation Errors:" + ), + React.createElement( + Text, + { color: "red" }, + "Please fix the errors before saving." + ) + ) + ); +}; + +module.exports = SchedulingScreen; diff --git a/src/tui/components/screens/TagAnalysisScreen.jsx b/src/tui/components/screens/TagAnalysisScreen.jsx new file mode 100644 index 0000000..a848654 --- /dev/null +++ b/src/tui/components/screens/TagAnalysisScreen.jsx @@ -0,0 +1,645 @@ +const React = require("react"); +const { Box, Text, useInput, useApp } = require("ink"); +const { useAppState } = require("../../providers/AppProvider.jsx"); +const SelectInput = require("ink-select-input").default; + +/** + * Tag Analysis Screen Component + * Analyzes product tags and provides insights for price update operations + * Requirements: 7.7, 7.8 + */ +const TagAnalysisScreen = () => { + const { appState, navigateBack } = useAppState(); + const { exit } = useApp(); + + // Mock tag analysis data + const mockTagAnalysis = { + totalProducts: 150, + tagCounts: [ + { tag: "sale", count: 45, percentage: 30.0 }, + { tag: "new", count: 32, percentage: 21.3 }, + { tag: "featured", count: 28, percentage: 18.7 }, + { tag: "clearance", count: 22, percentage: 14.7 }, + { tag: "limited", count: 15, percentage: 10.0 }, + { tag: "seasonal", count: 8, percentage: 5.3 }, + ], + priceRanges: { + sale: { min: 9.99, max: 199.99, average: 59.5 }, + new: { min: 19.99, max: 299.99, average: 89.75 }, + featured: { min: 29.99, max: 399.99, average: 129.5 }, + clearance: { min: 4.99, max: 149.99, average: 39.25 }, + limited: { min: 49.99, max: 499.99, average: 199.5 }, + seasonal: { min: 14.99, max: 249.99, average: 74.25 }, + }, + recommendations: [ + { + type: "high_impact", + title: "High-Impact Tags", + description: + "Tags with many products that would benefit most from price updates", + tags: ["sale", "clearance"], + reason: + "These tags have the highest product counts and are most likely to need price adjustments", + }, + { + type: "high_value", + title: "High-Value Tags", + description: "Tags with products having higher average prices", + tags: ["limited", "featured"], + reason: + "These tags contain premium products where price adjustments have the most financial impact", + }, + { + type: "caution", + title: "Use Caution", + description: "Tags that may require special handling", + tags: ["new", "seasonal"], + reason: + "These tags may have products with special pricing strategies that shouldn't be automatically adjusted", + }, + ], + }; + + // State for tag analysis + const [analysisData, setAnalysisData] = React.useState(mockTagAnalysis); + const [selectedTag, setSelectedTag] = React.useState(null); + const [showDetails, setShowDetails] = React.useState(false); + const [analysisType, setAnalysisType] = React.useState("overview"); + + // Analysis type options + const analysisOptions = [ + { value: "overview", label: "Overview" }, + { value: "distribution", label: "Tag Distribution" }, + { value: "pricing", label: "Pricing Analysis" }, + { value: "recommendations", label: "Recommendations" }, + ]; + + // Handle keyboard input + useInput((input, key) => { + if (key.escape) { + // Go back to main menu + navigateBack(); + } else if (key.upArrow) { + // Navigate up in list + if (selectedTag > 0) { + setSelectedTag(selectedTag - 1); + setShowDetails(false); + } + } else if (key.downArrow) { + // Navigate down in list + if (selectedTag < analysisData.tagCounts.length - 1) { + setSelectedTag(selectedTag + 1); + setShowDetails(false); + } + } else if (key.return || key.enter) { + // Toggle tag details + if (selectedTag !== null) { + setShowDetails(!showDetails); + } + } else if (key.r) { + // Refresh analysis + setAnalysisData(mockTagAnalysis); + setSelectedTag(null); + setShowDetails(false); + } + }); + + // Handle analysis type change + const handleAnalysisTypeChange = (option) => { + setAnalysisType(option.value); + }; + + // Get tag color based on count + const getTagColor = (count) => { + if (count >= 40) return "red"; + if (count >= 25) return "yellow"; + if (count >= 15) return "blue"; + return "green"; + }; + + // Render overview section + const renderOverview = () => + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "blue", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "blue" }, + "Tag Analysis Overview" + ), + React.createElement( + Text, + { color: "white" }, + `Total Products Analyzed: ${analysisData.totalProducts}` + ), + React.createElement( + Text, + { color: "white" }, + `Unique Tags Found: ${analysisData.tagCounts.length}` + ), + React.createElement( + Text, + { color: "white" }, + `Most Common Tag: ${analysisData.tagCounts[0]?.tag || "N/A"} (${ + analysisData.tagCounts[0]?.count || 0 + } products)` + ) + ) + ), + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "cyan", marginBottom: 1 }, + "Tag Distribution:" + ), + analysisData.tagCounts.map((tagInfo, index) => { + const isSelected = selectedTag === index; + const barWidth = Math.round( + (tagInfo.count / analysisData.totalProducts) * 40 + ); + + return React.createElement( + Box, + { + key: index, + borderStyle: "single", + borderColor: isSelected ? "blue" : "transparent", + paddingX: 1, + paddingY: 0.5, + marginBottom: 0.5, + backgroundColor: isSelected ? "blue" : undefined, + }, + React.createElement( + Box, + { flexDirection: "row", alignItems: "center", width: "100%" }, + React.createElement( + Text, + { + color: isSelected ? "white" : "white", + bold: true, + width: 15, + }, + tagInfo.tag + ), + React.createElement( + Text, + { + color: isSelected ? "white" : "gray", + width: 8, + }, + `${tagInfo.count}` + ), + React.createElement( + Text, + { + color: isSelected ? "white" : "gray", + width: 6, + }, + `${tagInfo.percentage.toFixed(1)}%` + ), + React.createElement( + Text, + { + color: getTagColor(tagInfo.count), + flexGrow: 1, + }, + "█".repeat(barWidth) + "░".repeat(40 - barWidth) + ) + ) + ); + }) + ) + ); + + // Render pricing analysis + const renderPricingAnalysis = () => + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "cyan", marginBottom: 1 }, + "Price Analysis by Tag:" + ), + analysisData.tagCounts.map((tagInfo, index) => { + const priceRange = analysisData.priceRanges[tagInfo.tag]; + if (!priceRange) return null; + + const isSelected = selectedTag === index; + + return React.createElement( + Box, + { + key: index, + borderStyle: "single", + borderColor: isSelected ? "blue" : "gray", + paddingX: 1, + paddingY: 1, + marginBottom: 1, + backgroundColor: isSelected ? "blue" : undefined, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { + color: isSelected ? "white" : "white", + bold: true, + }, + tagInfo.tag + ), + React.createElement( + Text, + { + color: isSelected ? "white" : "gray", + fontSize: "small", + }, + `${tagInfo.count} products` + ), + React.createElement( + Box, + { flexDirection: "row", marginTop: 1 }, + React.createElement( + Text, + { + color: isSelected ? "white" : "cyan", + bold: true, + }, + "Range: " + ), + React.createElement( + Text, + { + color: isSelected ? "white" : "white", + }, + `$${priceRange.min} - $${priceRange.max}` + ) + ), + React.createElement( + Box, + { flexDirection: "row" }, + React.createElement( + Text, + { + color: isSelected ? "white" : "cyan", + bold: true, + }, + "Avg: " + ), + React.createElement( + Text, + { + color: isSelected ? "white" : "white", + }, + `$${priceRange.average.toFixed(2)}` + ) + ) + ) + ); + }) + ); + + // Render recommendations + const renderRecommendations = () => + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "cyan", marginBottom: 1 }, + "Recommendations:" + ), + analysisData.recommendations.map((rec, index) => { + const getTypeColor = (type) => { + switch (type) { + case "high_impact": + return "green"; + case "high_value": + return "blue"; + case "caution": + return "yellow"; + default: + return "white"; + } + }; + + const getTypeIcon = (type) => { + switch (type) { + case "high_impact": + return "⭐"; + case "high_value": + return "💎"; + case "caution": + return "⚠️"; + default: + return "ℹ️"; + } + }; + + return React.createElement( + Box, + { + key: index, + borderStyle: "single", + borderColor: getTypeColor(rec.type), + paddingX: 1, + paddingY: 1, + marginBottom: 1, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Box, + { flexDirection: "row", alignItems: "center", marginBottom: 1 }, + React.createElement( + Text, + { + color: getTypeColor(rec.type), + bold: true, + }, + `${getTypeIcon(rec.type)} ${rec.title}` + ) + ), + React.createElement( + Text, + { color: "white", marginBottom: 1 }, + rec.description + ), + React.createElement( + Text, + { color: "gray", italic: true, marginBottom: 1 }, + rec.reason + ), + React.createElement( + Text, + { color: "cyan", bold: true }, + "Tags: " + rec.tags.join(", ") + ) + ) + ); + }) + ); + + // Render current analysis view + const renderCurrentView = () => { + switch (analysisType) { + case "overview": + return renderOverview(); + case "pricing": + return renderPricingAnalysis(); + case "recommendations": + return renderRecommendations(); + default: + return renderOverview(); + } + }; + + return React.createElement( + Box, + { flexDirection: "column", padding: 2, flexGrow: 1 }, + // Header + React.createElement( + Box, + { flexDirection: "column", marginBottom: 2 }, + React.createElement( + Text, + { bold: true, color: "cyan" }, + "🏷️ Tag Analysis" + ), + React.createElement( + Text, + { color: "gray" }, + "Analyze product tags to optimize price update operations" + ) + ), + + // Analysis type selector + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "blue", + paddingX: 1, + paddingY: 1, + marginBottom: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "blue" }, + "Analysis Type:" + ), + React.createElement(SelectInput, { + items: analysisOptions, + selectedIndex: analysisOptions.findIndex( + (opt) => opt.value === analysisType + ), + onSelect: handleAnalysisTypeChange, + itemComponent: ({ label, isSelected }) => + React.createElement( + Text, + { + color: isSelected ? "blue" : "white", + bold: isSelected, + }, + label + ), + }) + ) + ), + + // Current analysis view + React.createElement( + Box, + { + flexGrow: 1, + borderStyle: "single", + borderColor: "gray", + flexDirection: "column", + padding: 1, + }, + renderCurrentView() + ), + + // Tag details (when selected) + showDetails && + selectedTag !== null && + analysisData.tagCounts[selectedTag] && + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "green", + paddingX: 1, + paddingY: 1, + marginTop: 2, + }, + React.createElement( + Box, + { flexDirection: "column" }, + React.createElement( + Text, + { bold: true, color: "green" }, + "Tag Details:" + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement(Text, { color: "white", bold: true }, "Tag: "), + React.createElement( + Text, + { color: "white" }, + analysisData.tagCounts[selectedTag].tag + ) + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { color: "white", bold: true }, + "Product Count: " + ), + React.createElement( + Text, + { color: "white" }, + analysisData.tagCounts[selectedTag].count + ) + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { color: "white", bold: true }, + "Percentage: " + ), + React.createElement( + Text, + { color: "white" }, + `${analysisData.tagCounts[selectedTag].percentage.toFixed(1)}%` + ) + ), + analysisData.priceRanges[analysisData.tagCounts[selectedTag].tag] && + React.createElement( + Box, + { flexDirection: "column", marginTop: 1 }, + React.createElement( + Text, + { color: "white", bold: true }, + "Pricing Information:" + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { color: "cyan", bold: true }, + "Min: " + ), + React.createElement( + Text, + { color: "white" }, + `$${ + analysisData.priceRanges[ + analysisData.tagCounts[selectedTag].tag + ].min + }` + ) + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { color: "cyan", bold: true }, + "Max: " + ), + React.createElement( + Text, + { color: "white" }, + `$${ + analysisData.priceRanges[ + analysisData.tagCounts[selectedTag].tag + ].max + }` + ) + ), + React.createElement( + Box, + { flexDirection: "row", alignItems: "center" }, + React.createElement( + Text, + { color: "cyan", bold: true }, + "Average: " + ), + React.createElement( + Text, + { color: "white" }, + `$${analysisData.priceRanges[ + analysisData.tagCounts[selectedTag].tag + ].average.toFixed(2)}` + ) + ) + ) + ) + ), + + // Instructions + React.createElement( + Box, + { + flexDirection: "column", + marginTop: 2, + borderTopStyle: "single", + borderColor: "gray", + paddingTop: 2, + }, + React.createElement(Text, { color: "gray" }, "Controls:"), + React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate items"), + React.createElement(Text, { color: "gray" }, " Enter - View details"), + React.createElement(Text, { color: "gray" }, " R - Refresh analysis"), + React.createElement(Text, { color: "gray" }, " Esc - Back to menu") + ), + + // Status bar + React.createElement( + Box, + { + borderStyle: "single", + borderColor: "gray", + paddingX: 1, + paddingY: 0.5, + marginTop: 1, + }, + React.createElement( + Text, + { color: "gray", fontSize: "small" }, + `View: ${ + analysisOptions.find((opt) => opt.value === analysisType)?.label || + "Overview" + } | Selected: ${selectedTag !== null ? `#${selectedTag + 1}` : "None"}` + ) + ) + ); +}; + +module.exports = TagAnalysisScreen; diff --git a/src/tui/providers/AppProvider.jsx b/src/tui/providers/AppProvider.jsx index ea461e9..6dbc833 100644 --- a/src/tui/providers/AppProvider.jsx +++ b/src/tui/providers/AppProvider.jsx @@ -14,7 +14,7 @@ const initialState = { currentScreen: "main-menu", navigationHistory: [], configuration: { - shopifyDomain: "", + shopDomain: "", accessToken: "", targetTag: "", priceAdjustment: 0,