451 lines
17 KiB
TypeScript
451 lines
17 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { calculateNextScheduledTimestampFromNow } from "../app/v3/utils/calculateNextSchedule.server";
|
|
|
|
describe("calculateNextScheduledTimestampFromNow", () => {
|
|
beforeEach(() => {
|
|
// Mock the current time to make tests deterministic
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2024-01-01T12:30:00.000Z"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("should calculate next run time for a recent timestamp", () => {
|
|
const schedule = "0 * * * *"; // Every hour
|
|
const _lastRun = new Date("2024-01-01T11:00:00.000Z"); // 1.5 hours ago
|
|
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
|
|
// Should be 13:00 (next hour after current time 12:30)
|
|
expect(nextRun).toEqual(new Date("2024-01-01T13:00:00.000Z"));
|
|
});
|
|
|
|
test("should handle timezone correctly", () => {
|
|
const schedule = "0 * * * *"; // Every hour
|
|
const _lastRun = new Date("2024-01-01T11:00:00.000Z");
|
|
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, "America/New_York");
|
|
|
|
// The exact time will depend on timezone calculation, but should be in the future
|
|
expect(nextRun).toBeInstanceOf(Date);
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
});
|
|
|
|
test("should efficiently handle very old timestamps (performance fix)", () => {
|
|
const schedule = "*/1 * * * *"; // Every minute
|
|
const _veryOldTimestamp = new Date("2020-01-01T00:00:00.000Z"); // 4 years ago
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should complete quickly (under 10ms) instead of iterating millions of times
|
|
expect(duration).toBeLessThan(10);
|
|
|
|
// Should still return a valid future timestamp
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
|
|
// Should be the next minute after current time (12:31)
|
|
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
|
|
});
|
|
|
|
test("should still work correctly when timestamp is within threshold", () => {
|
|
const schedule = "0 */2 * * *"; // Every 2 hours
|
|
const _recentTimestamp = new Date("2024-01-01T10:00:00.000Z"); // 2.5 hours ago
|
|
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
|
|
// Should properly iterate: 10:00 -> 12:00 -> 14:00 (since current time is 12:30)
|
|
expect(nextRun).toEqual(new Date("2024-01-01T14:00:00.000Z"));
|
|
});
|
|
|
|
test("should handle frequent schedules with old timestamps efficiently", () => {
|
|
const schedule = "*/5 * * * *"; // Every 5 minutes
|
|
const _oldTimestamp = new Date("2023-12-01T00:00:00.000Z"); // Over a month ago
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should be fast due to dynamic skip-ahead optimization
|
|
expect(duration).toBeLessThan(10);
|
|
|
|
// Should return next 5-minute interval after current time
|
|
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
|
|
});
|
|
|
|
test("should work with complex cron expressions", () => {
|
|
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
|
|
const _oldTimestamp = new Date("2022-01-01T00:00:00.000Z"); // Very old (beyond 1hr threshold)
|
|
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
|
|
// Should return a valid future Monday at 9 AM
|
|
expect(nextRun.getHours()).toBe(9);
|
|
expect(nextRun.getMinutes()).toBe(0);
|
|
expect(nextRun.getDay()).toBe(1); // Monday
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
});
|
|
|
|
test("performance: dynamic optimization for extreme scenarios", () => {
|
|
// This test simulates the exact scenario that was causing event loop lag
|
|
const schedule = "* * * * *"; // Every minute (very frequent)
|
|
const _extremelyOldTimestamp = new Date("2000-01-01T00:00:00.000Z"); // 24 years ago
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should complete extremely quickly due to dynamic skip-ahead
|
|
expect(duration).toBeLessThan(5);
|
|
|
|
// Should still return the correct next minute
|
|
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
|
|
});
|
|
|
|
test("dynamic optimization: 23h59m old now handled efficiently", () => {
|
|
// This should now be handled efficiently regardless of being "just under" a threshold
|
|
const schedule = "* * * * *"; // Every minute
|
|
const _oldTimestamp = new Date("2023-12-31T12:31:00.000Z"); // 23h59m ago
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should be fast due to dynamic skip-ahead (1439 steps > 10 threshold)
|
|
expect(duration).toBeLessThan(10);
|
|
|
|
// Should return correct result
|
|
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
|
|
});
|
|
|
|
test("small intervals still use normal iteration", () => {
|
|
// This should use normal iteration since it's only a few steps
|
|
const schedule = "*/5 * * * *"; // Every 5 minutes
|
|
const _recentTimestamp = new Date("2024-01-01T12:00:00.000Z"); // 30 minutes ago (6 steps)
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should still be reasonably fast with normal iteration
|
|
expect(duration).toBeLessThan(50);
|
|
|
|
// Should return next 5-minute interval
|
|
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
|
|
});
|
|
|
|
test("should work with weekly schedules and old timestamps", () => {
|
|
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
|
|
const _oldTimestamp = new Date("2023-12-25T09:00:00.000Z"); // Old Monday
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should be fast and still calculate correctly from the old timestamp
|
|
expect(duration).toBeLessThan(50);
|
|
|
|
// Should return a valid future Monday at 9 AM
|
|
expect(nextRun.getHours()).toBe(9);
|
|
expect(nextRun.getMinutes()).toBe(0);
|
|
expect(nextRun.getDay()).toBe(1); // Monday
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
});
|
|
|
|
test("weekly schedule with 2-hour old timestamp should calculate properly", () => {
|
|
// This tests your specific concern about weekly schedules
|
|
const schedule = "0 14 * * SUN"; // Every Sunday at 2 PM
|
|
const _twoHoursAgo = new Date("2024-01-01T10:30:00.000Z"); // 2 hours before current time (12:30)
|
|
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
|
|
// Should properly calculate the next Sunday at 2 PM, not skip to "now"
|
|
expect(nextRun.getHours()).toBe(14);
|
|
expect(nextRun.getMinutes()).toBe(0);
|
|
expect(nextRun.getDay()).toBe(0); // Sunday
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
});
|
|
});
|
|
|
|
describe("calculateNextScheduledTimestampFromNow - Fuzzy Testing", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2024-01-15T12:30:00.000Z")); // Monday, mid-day
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Helper function to generate random cron expressions
|
|
function generateRandomCronExpression(): string {
|
|
const patterns = [
|
|
// Minutes
|
|
"*/1 * * * *", // Every minute
|
|
"*/5 * * * *", // Every 5 minutes
|
|
"*/15 * * * *", // Every 15 minutes
|
|
"30 * * * *", // Every hour at 30 minutes
|
|
|
|
// Hours
|
|
"0 * * * *", // Every hour
|
|
"0 */2 * * *", // Every 2 hours
|
|
"0 */6 * * *", // Every 6 hours
|
|
"0 9 * * *", // Daily at 9 AM
|
|
"0 14 * * *", // Daily at 2 PM
|
|
|
|
// Days
|
|
"0 9 * * 1", // Every Monday at 9 AM
|
|
"0 14 * * 5", // Every Friday at 2 PM
|
|
"0 10 * * 1-5", // Weekdays at 10 AM
|
|
"0 0 * * 0", // Every Sunday at midnight
|
|
|
|
// Weekly/Monthly
|
|
"0 9 * * MON", // Every Monday at 9 AM
|
|
"0 12 1 * *", // First of every month at noon
|
|
"0 15 15 * *", // 15th of every month at 3 PM
|
|
|
|
// Complex patterns
|
|
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
|
|
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
|
|
];
|
|
|
|
return patterns[Math.floor(Math.random() * patterns.length)];
|
|
}
|
|
|
|
// Helper function to generate random timestamps
|
|
function generateRandomTimestamp(): Date {
|
|
const now = Date.now();
|
|
const possibilities = [
|
|
// Recent timestamps (within last few hours)
|
|
new Date(now - Math.random() * 4 * 60 * 60 * 1000),
|
|
|
|
// Old timestamps (days ago)
|
|
new Date(now - Math.random() * 30 * 24 * 60 * 60 * 1000),
|
|
|
|
// Very old timestamps (months/years ago)
|
|
new Date(now - Math.random() * 365 * 24 * 60 * 60 * 1000),
|
|
|
|
// Extremely old timestamps
|
|
new Date(now - Math.random() * 10 * 365 * 24 * 60 * 60 * 1000),
|
|
|
|
// Edge case: exactly now
|
|
new Date(now),
|
|
|
|
// Edge case: 1ms ago
|
|
new Date(now - 1),
|
|
|
|
// Edge case: future timestamp (should be handled gracefully)
|
|
new Date(now + Math.random() * 24 * 60 * 60 * 1000),
|
|
];
|
|
|
|
return possibilities[Math.floor(Math.random() * possibilities.length)];
|
|
}
|
|
|
|
test("fuzzy test: invariants should hold for random scenarios", () => {
|
|
const numTests = 50;
|
|
|
|
for (let i = 0; i < numTests; i++) {
|
|
const schedule = generateRandomCronExpression();
|
|
const lastTimestamp = generateRandomTimestamp();
|
|
const timezone = Math.random() > 0.7 ? "America/New_York" : null;
|
|
|
|
try {
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, timezone);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Invariant 1: Result should always be a valid Date
|
|
expect(nextRun).toBeInstanceOf(Date);
|
|
expect(nextRun.getTime()).not.toBeNaN();
|
|
|
|
// Invariant 2: Result should be in the future (or equal to now if lastTimestamp was in future)
|
|
if (lastTimestamp.getTime() <= Date.now()) {
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
}
|
|
|
|
// Invariant 3: Performance should be reasonable (no event loop lag)
|
|
expect(duration).toBeLessThan(100); // Should complete within 100ms
|
|
|
|
// Invariant 4: Function should be deterministic
|
|
const nextRun2 = calculateNextScheduledTimestampFromNow(schedule, timezone);
|
|
expect(nextRun.getTime()).toBe(nextRun2.getTime());
|
|
} catch (error) {
|
|
// If there's an error, log the inputs for debugging
|
|
console.error(
|
|
`Failed with schedule: ${schedule}, lastTimestamp: ${lastTimestamp.toISOString()}, timezone: ${timezone}`
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("fuzzy test: performance under stress with frequent schedules", () => {
|
|
const frequentSchedules = ["* * * * *", "*/2 * * * *", "*/5 * * * *"];
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
const schedule = frequentSchedules[Math.floor(Math.random() * frequentSchedules.length)];
|
|
|
|
// Generate very old timestamps that would cause many iterations without optimization
|
|
const _veryOldTimestamp = new Date(
|
|
Date.now() - Math.random() * 5 * 365 * 24 * 60 * 60 * 1000
|
|
);
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// Should complete quickly even with very old timestamps
|
|
expect(duration).toBeLessThan(20);
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
}
|
|
});
|
|
|
|
test("fuzzy test: edge cases around daylight saving time", () => {
|
|
// Test around DST transition dates (spring forward, fall back)
|
|
const dstTestDates = [
|
|
"2024-03-10T06:00:00.000Z", // Around US spring DST
|
|
"2024-11-03T06:00:00.000Z", // Around US fall DST
|
|
"2024-03-31T01:00:00.000Z", // Around EU spring DST
|
|
"2024-10-27T01:00:00.000Z", // Around EU fall DST
|
|
];
|
|
|
|
const timezones = ["America/New_York", "Europe/London", "America/Los_Angeles"];
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
const schedule = generateRandomCronExpression();
|
|
const testDate = dstTestDates[Math.floor(Math.random() * dstTestDates.length)];
|
|
const timezone = timezones[Math.floor(Math.random() * timezones.length)];
|
|
|
|
vi.setSystemTime(new Date(testDate));
|
|
|
|
const _lastTimestamp = new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, timezone);
|
|
|
|
// Should handle DST transitions gracefully
|
|
expect(nextRun).toBeInstanceOf(Date);
|
|
expect(nextRun.getTime()).not.toBeNaN();
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
}
|
|
});
|
|
|
|
test("fuzzy test: boundary conditions", () => {
|
|
const boundaryTests = [
|
|
// End of month transitions
|
|
{ time: "2024-02-29T23:59:59.000Z", schedule: "0 0 1 * *" }, // Leap year to March 1st
|
|
{ time: "2024-04-30T23:59:59.000Z", schedule: "0 0 31 * *" }, // April 30th to May 31st
|
|
|
|
// End of year
|
|
{ time: "2024-12-31T23:59:59.000Z", schedule: "0 0 1 1 *" }, // New Year
|
|
|
|
// Weekday transitions
|
|
{ time: "2024-01-15T06:59:59.000Z", schedule: "0 7 * * MON" }, // Monday morning
|
|
|
|
// Hour boundaries
|
|
{ time: "2024-01-15T11:59:59.000Z", schedule: "0 12 * * *" }, // Noon
|
|
{ time: "2024-01-15T23:59:59.000Z", schedule: "0 0 * * *" }, // Midnight
|
|
];
|
|
|
|
for (const test of boundaryTests) {
|
|
vi.setSystemTime(new Date(test.time));
|
|
|
|
// Test with timestamps both before and after the boundary
|
|
const _beforeBoundary = new Date(Date.now() - 1000);
|
|
const _afterBoundary = new Date(Date.now() + 1000);
|
|
|
|
const nextRun1 = calculateNextScheduledTimestampFromNow(test.schedule, null);
|
|
const nextRun2 = calculateNextScheduledTimestampFromNow(test.schedule, null);
|
|
|
|
expect(nextRun1.getTime()).toBeGreaterThan(Date.now());
|
|
expect(nextRun2.getTime()).toBeGreaterThan(Date.now());
|
|
}
|
|
});
|
|
|
|
test("fuzzy test: complex cron expressions", () => {
|
|
const complexSchedules = [
|
|
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
|
|
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
|
|
"0 0 1,15 * *", // 1st and 15th of every month
|
|
"0 12 * * MON#2", // Second Monday of every month (if supported)
|
|
"0 0 L * *", // Last day of month (if supported)
|
|
"15,45 */2 * * *", // 15 and 45 minutes past every 2nd hour
|
|
];
|
|
|
|
for (let i = 0; i < 30; i++) {
|
|
const schedule = complexSchedules[Math.floor(Math.random() * complexSchedules.length)];
|
|
const lastTimestamp = generateRandomTimestamp();
|
|
|
|
try {
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
expect(nextRun).toBeInstanceOf(Date);
|
|
expect(duration).toBeLessThan(100);
|
|
|
|
if (lastTimestamp.getTime() <= Date.now()) {
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
}
|
|
} catch (error) {
|
|
// Some complex expressions might not be supported, that's okay
|
|
if (
|
|
!(error as Error).message.includes("not supported") &&
|
|
!(error as Error).message.includes("Invalid")
|
|
) {
|
|
console.error(`Unexpected error with schedule: ${schedule}`);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("fuzzy test: consistency across multiple calls", () => {
|
|
// Test that the function is consistent when called multiple times with same inputs
|
|
for (let i = 0; i < 20; i++) {
|
|
const schedule = generateRandomCronExpression();
|
|
const _lastTimestamp = generateRandomTimestamp();
|
|
const timezone = Math.random() > 0.5 ? "UTC" : "America/New_York";
|
|
|
|
const results: Date[] = [];
|
|
for (let j = 0; j < 5; j++) {
|
|
results.push(calculateNextScheduledTimestampFromNow(schedule, timezone));
|
|
}
|
|
|
|
// All results should be identical
|
|
for (let j = 1; j < results.length; j++) {
|
|
expect(results[j].getTime()).toBe(results[0].getTime());
|
|
}
|
|
}
|
|
});
|
|
|
|
test("fuzzy test: optimization threshold boundary (around 10 steps)", () => {
|
|
// Test cases specifically around the 10-step optimization threshold
|
|
const testCases = [
|
|
{ schedule: "*/5 * * * *", minutesAgo: 50 }, // Exactly 10 steps
|
|
{ schedule: "*/5 * * * *", minutesAgo: 55 }, // 11 steps (should optimize)
|
|
{ schedule: "*/5 * * * *", minutesAgo: 45 }, // 9 steps (should not optimize)
|
|
{ schedule: "*/10 * * * *", minutesAgo: 100 }, // Exactly 10 steps
|
|
{ schedule: "*/10 * * * *", minutesAgo: 110 }, // 11 steps (should optimize)
|
|
{ schedule: "*/15 * * * *", minutesAgo: 150 }, // Exactly 10 steps
|
|
{ schedule: "*/1 * * * *", minutesAgo: 10 }, // Exactly 10 steps
|
|
{ schedule: "*/1 * * * *", minutesAgo: 11 }, // 11 steps (should optimize)
|
|
];
|
|
|
|
for (const testCase of testCases) {
|
|
const _lastTimestamp = new Date(Date.now() - testCase.minutesAgo * 60 * 1000);
|
|
|
|
const startTime = performance.now();
|
|
const nextRun = calculateNextScheduledTimestampFromNow(testCase.schedule, null);
|
|
const duration = performance.now() - startTime;
|
|
|
|
// All cases should complete quickly and return valid results
|
|
expect(duration).toBeLessThan(50);
|
|
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
|
expect(nextRun).toBeInstanceOf(Date);
|
|
}
|
|
});
|
|
});
|