/** * 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); }); }); });