/** * Unit tests for ScheduleService (Schedule Management) functionality * Tests Requirements 1.6, 5.1 from the tui-missing-screens spec */ const ScheduleService = require("../../src/services/scheduleManagement"); const fs = require("fs").promises; const path = require("path"); describe("ScheduleService", () => { let scheduleService; let testSchedulesFile; beforeEach(() => { // Use a unique test file for each test to avoid conflicts testSchedulesFile = `test-schedules-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}.json`; // Create a custom ScheduleService instance that uses our test file scheduleService = new ScheduleService(); scheduleService.schedulesFile = path.join(process.cwd(), testSchedulesFile); }); afterEach(async () => { // Clean up test file after each test try { await fs.unlink(testSchedulesFile); } catch (error) { // File might not exist, that's okay } }); describe("validateSchedule", () => { test("should return null for valid schedule", () => { const validSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), recurrence: "daily", enabled: true, config: { targetTag: "sale" }, status: "pending", }; const result = scheduleService.validateSchedule(validSchedule); expect(result).toBeNull(); }); test("should return error for missing schedule object", () => { const result = scheduleService.validateSchedule(null); expect(result).toBe("Schedule object is required"); }); test("should return error for missing operation type", () => { const invalidSchedule = { scheduledTime: new Date(Date.now() + 86400000), }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe("Operation type is required"); }); test("should return error for invalid operation type", () => { const invalidSchedule = { operationType: "invalid", scheduledTime: new Date(Date.now() + 86400000), }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe('Operation type must be "update" or "rollback"'); }); test("should return error for missing scheduled time", () => { const invalidSchedule = { operationType: "update", }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe("Scheduled time is required"); }); test("should return error for invalid scheduled time", () => { const invalidSchedule = { operationType: "update", scheduledTime: "invalid date", }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe("Scheduled time must be a valid date"); }); test("should return error for past scheduled time on new schedules", () => { const invalidSchedule = { operationType: "update", scheduledTime: new Date(Date.now() - 86400000), // Yesterday }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe("Scheduled time must be in the future"); }); test("should allow past scheduled time for existing schedules", () => { const existingSchedule = { id: "existing_schedule", operationType: "update", scheduledTime: new Date(Date.now() - 86400000), // Yesterday }; const result = scheduleService.validateSchedule(existingSchedule); expect(result).toBeNull(); }); test("should return error for invalid recurrence", () => { const invalidSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), recurrence: "invalid", }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe( "Recurrence must be one of: once, daily, weekly, monthly" ); }); test("should return error for invalid status", () => { const invalidSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), status: "invalid", }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe( "Status must be one of: pending, completed, failed, cancelled" ); }); test("should return error for invalid enabled flag", () => { const invalidSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), enabled: "not boolean", }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe("Enabled must be a boolean value"); }); test("should return error for invalid config", () => { const invalidSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), config: "not an object", }; const result = scheduleService.validateSchedule(invalidSchedule); expect(result).toBe("Config must be an object"); }); }); describe("loadSchedules", () => { test("should return empty array when schedules file does not exist", async () => { const result = await scheduleService.loadSchedules(); expect(result).toEqual([]); }); test("should load schedules from JSON file and convert date strings to Date objects", async () => { const mockScheduleData = [ { id: "schedule_1", operationType: "update", scheduledTime: "2024-12-01T10:00:00.000Z", recurrence: "once", enabled: true, config: { targetTag: "sale" }, status: "pending", createdAt: "2024-11-01T10:00:00.000Z", lastExecuted: null, nextExecution: null, }, ]; // Write test data to file await fs.writeFile( scheduleService.schedulesFile, JSON.stringify(mockScheduleData), "utf8" ); const result = await scheduleService.loadSchedules(); expect(result).toHaveLength(1); expect(result[0].scheduledTime).toBeInstanceOf(Date); expect(result[0].createdAt).toBeInstanceOf(Date); expect(result[0].lastExecuted).toBeNull(); expect(result[0].nextExecution).toBeNull(); }); test("should throw error for invalid JSON", async () => { // Write invalid JSON to file await fs.writeFile(scheduleService.schedulesFile, "invalid json", "utf8"); await expect(scheduleService.loadSchedules()).rejects.toThrow(); }); }); describe("saveSchedules", () => { test("should save schedules to JSON file with date objects converted to ISO strings", async () => { const schedules = [ { id: "schedule_1", operationType: "update", scheduledTime: new Date("2024-12-01T10:00:00.000Z"), recurrence: "once", enabled: true, config: { targetTag: "sale" }, status: "pending", createdAt: new Date("2024-11-01T10:00:00.000Z"), lastExecuted: null, nextExecution: null, }, ]; await scheduleService.saveSchedules(schedules); // Read the file back and verify content const fileContent = await fs.readFile( scheduleService.schedulesFile, "utf8" ); const savedData = JSON.parse(fileContent); expect(savedData).toHaveLength(1); expect(savedData[0].scheduledTime).toBe("2024-12-01T10:00:00.000Z"); expect(savedData[0].createdAt).toBe("2024-11-01T10:00:00.000Z"); }); }); describe("addSchedule", () => { test("should add a valid schedule with generated ID and defaults", async () => { const newSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), // Tomorrow recurrence: "daily", config: { targetTag: "sale" }, }; const result = await scheduleService.addSchedule(newSchedule); expect(result.id).toMatch(/^schedule_\d+_[a-z0-9]+$/); expect(result.operationType).toBe("update"); expect(result.scheduledTime).toBeInstanceOf(Date); expect(result.recurrence).toBe("daily"); expect(result.enabled).toBe(true); expect(result.status).toBe("pending"); expect(result.createdAt).toBeInstanceOf(Date); expect(result.nextExecution).toBeInstanceOf(Date); }); test("should apply default values for optional fields", async () => { const newSchedule = { operationType: "rollback", scheduledTime: new Date(Date.now() + 86400000), }; const result = await scheduleService.addSchedule(newSchedule); expect(result.recurrence).toBe("once"); expect(result.enabled).toBe(true); expect(result.config).toEqual({}); expect(result.nextExecution).toBeNull(); }); test("should throw error for invalid schedule", async () => { const invalidSchedule = { operationType: "invalid", scheduledTime: new Date(Date.now() + 86400000), }; await expect( scheduleService.addSchedule(invalidSchedule) ).rejects.toThrow( 'Invalid schedule: Operation type must be "update" or "rollback"' ); }); }); describe("updateSchedule", () => { test("should update existing schedule", async () => { // First add a schedule const newSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), recurrence: "once", config: { targetTag: "sale" }, }; const addedSchedule = await scheduleService.addSchedule(newSchedule); // Then update it const updates = { enabled: false, recurrence: "weekly", }; const result = await scheduleService.updateSchedule( addedSchedule.id, updates ); expect(result.enabled).toBe(false); expect(result.recurrence).toBe("weekly"); expect(result.id).toBe(addedSchedule.id); }); test("should recalculate nextExecution when scheduledTime is updated", async () => { // First add a schedule const newSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), recurrence: "daily", }; const addedSchedule = await scheduleService.addSchedule(newSchedule); // Update with new scheduled time const newScheduledTime = new Date(Date.now() + 172800000); // 2 days from now const updates = { scheduledTime: newScheduledTime, recurrence: "daily", }; const result = await scheduleService.updateSchedule( addedSchedule.id, updates ); expect(result.scheduledTime).toEqual(newScheduledTime); expect(result.nextExecution).toBeInstanceOf(Date); expect(result.nextExecution.getTime()).toBeGreaterThan( newScheduledTime.getTime() ); }); test("should throw error for non-existent schedule", async () => { await expect( scheduleService.updateSchedule("non_existent", { enabled: false }) ).rejects.toThrow("Schedule with ID non_existent not found"); }); test("should throw error for invalid updates", async () => { // First add a schedule const newSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), }; const addedSchedule = await scheduleService.addSchedule(newSchedule); const invalidUpdates = { operationType: "invalid", }; await expect( scheduleService.updateSchedule(addedSchedule.id, invalidUpdates) ).rejects.toThrow("Invalid schedule update"); }); }); describe("deleteSchedule", () => { test("should delete existing schedule and return true", async () => { // First add a schedule const newSchedule = { operationType: "update", scheduledTime: new Date(Date.now() + 86400000), config: { targetTag: "sale" }, }; const addedSchedule = await scheduleService.addSchedule(newSchedule); // Then delete it const result = await scheduleService.deleteSchedule(addedSchedule.id); expect(result).toBe(true); // Verify it's gone const schedules = await scheduleService.loadSchedules(); expect(schedules).toHaveLength(0); }); test("should return false for non-existent schedule", async () => { const result = await scheduleService.deleteSchedule("non_existent"); expect(result).toBe(false); }); }); describe("helper methods", () => { test("should get schedules by status", async () => { // Add schedules with different statuses const schedule1 = await scheduleService.addSchedule({ operationType: "update", scheduledTime: new Date(Date.now() + 86400000), }); await scheduleService.updateSchedule(schedule1.id, { status: "completed", }); const schedule2 = await scheduleService.addSchedule({ operationType: "rollback", scheduledTime: new Date(Date.now() + 172800000), }); const pendingSchedules = await scheduleService.getSchedulesByStatus( "pending" ); const completedSchedules = await scheduleService.getSchedulesByStatus( "completed" ); expect(pendingSchedules).toHaveLength(1); expect(completedSchedules).toHaveLength(1); expect(pendingSchedules[0].id).toBe(schedule2.id); expect(completedSchedules[0].id).toBe(schedule1.id); }); test("should get schedules by operation type", async () => { const schedule1 = await scheduleService.addSchedule({ operationType: "update", scheduledTime: new Date(Date.now() + 86400000), }); const schedule2 = await scheduleService.addSchedule({ operationType: "rollback", scheduledTime: new Date(Date.now() + 172800000), }); const schedule3 = await scheduleService.addSchedule({ operationType: "update", scheduledTime: new Date(Date.now() + 259200000), }); const updateSchedules = await scheduleService.getSchedulesByOperationType( "update" ); const rollbackSchedules = await scheduleService.getSchedulesByOperationType("rollback"); expect(updateSchedules).toHaveLength(2); expect(rollbackSchedules).toHaveLength(1); expect(rollbackSchedules[0].id).toBe(schedule2.id); }); test("should get enabled schedules", async () => { const schedule1 = await scheduleService.addSchedule({ operationType: "update", scheduledTime: new Date(Date.now() + 86400000), enabled: true, }); const schedule2 = await scheduleService.addSchedule({ operationType: "rollback", scheduledTime: new Date(Date.now() + 172800000), enabled: false, }); const schedule3 = await scheduleService.addSchedule({ operationType: "update", scheduledTime: new Date(Date.now() + 259200000), enabled: true, }); const enabledSchedules = await scheduleService.getEnabledSchedules(); expect(enabledSchedules).toHaveLength(2); expect(enabledSchedules.every((s) => s.enabled === true)).toBe(true); }); test("should mark schedule as completed", async () => { const schedule = await scheduleService.addSchedule({ operationType: "update", scheduledTime: new Date(Date.now() + 86400000), }); const result = await scheduleService.markScheduleCompleted(schedule.id); expect(result.status).toBe("completed"); expect(result.lastExecuted).toBeInstanceOf(Date); }); test("should mark schedule as failed", async () => { const schedule = await scheduleService.addSchedule({ operationType: "update", scheduledTime: new Date(Date.now() + 86400000), }); const result = await scheduleService.markScheduleFailed( schedule.id, "Test error" ); expect(result.status).toBe("failed"); expect(result.lastExecuted).toBeInstanceOf(Date); expect(result.errorMessage).toBe("Test error"); }); }); describe("private methods", () => { test("should generate unique IDs", () => { const existingSchedules = [ { id: "schedule_123_abc" }, { id: "schedule_456_def" }, ]; const id1 = scheduleService._generateId(existingSchedules); const id2 = scheduleService._generateId(existingSchedules); expect(id1).toMatch(/^schedule_\d+_[a-z0-9]+$/); expect(id2).toMatch(/^schedule_\d+_[a-z0-9]+$/); expect(id1).not.toBe(id2); expect(existingSchedules.some((s) => s.id === id1)).toBe(false); expect(existingSchedules.some((s) => s.id === id2)).toBe(false); }); test("should calculate next execution for daily recurrence", () => { const scheduledTime = new Date("2024-12-01T10:00:00.000Z"); const nextExecution = scheduleService._calculateNextExecution( scheduledTime, "daily" ); expect(nextExecution).toBeInstanceOf(Date); expect(nextExecution.getDate()).toBe(scheduledTime.getDate() + 1); }); test("should calculate next execution for weekly recurrence", () => { const scheduledTime = new Date("2024-12-01T10:00:00.000Z"); const nextExecution = scheduleService._calculateNextExecution( scheduledTime, "weekly" ); expect(nextExecution).toBeInstanceOf(Date); expect(nextExecution.getDate()).toBe(scheduledTime.getDate() + 7); }); test("should calculate next execution for monthly recurrence", () => { const scheduledTime = new Date("2024-11-01T10:00:00.000Z"); // November instead of December const nextExecution = scheduleService._calculateNextExecution( scheduledTime, "monthly" ); expect(nextExecution).toBeInstanceOf(Date); expect(nextExecution.getMonth()).toBe(scheduledTime.getMonth() + 1); }); test("should return null for once recurrence", () => { const scheduledTime = new Date("2024-12-01T10:00:00.000Z"); const nextExecution = scheduleService._calculateNextExecution( scheduledTime, "once" ); expect(nextExecution).toBeNull(); }); test("should return null for invalid recurrence", () => { const scheduledTime = new Date("2024-12-01T10:00:00.000Z"); const nextExecution = scheduleService._calculateNextExecution( scheduledTime, "invalid" ); expect(nextExecution).toBeNull(); }); }); });