Skip to content

Commit 60903a9

Browse files
committed
Add additional tests
1 parent c38c473 commit 60903a9

File tree

2 files changed

+278
-0
lines changed

2 files changed

+278
-0
lines changed

apps/webapp/app/v3/services/registerNextTaskScheduleInstance.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export class RegisterNextTaskScheduleInstanceService extends BaseService {
2828
"task_schedule_generator_expression",
2929
instance.taskSchedule.generatorExpression
3030
);
31+
span.setAttribute(
32+
"last_scheduled_timestamp",
33+
instance.lastScheduledTimestamp?.toISOString() ?? new Date().toISOString()
34+
);
3135

3236
return calculateNextScheduledTimestamp(
3337
instance.taskSchedule.generatorExpression,

apps/webapp/test/calculateNextSchedule.test.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,277 @@ describe("calculateNextScheduledTimestamp", () => {
169169
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
170170
});
171171
});
172+
173+
describe("calculateNextScheduledTimestamp - Fuzzy Testing", () => {
174+
beforeEach(() => {
175+
vi.useFakeTimers();
176+
vi.setSystemTime(new Date("2024-01-15T12:30:00.000Z")); // Monday, mid-day
177+
});
178+
179+
afterEach(() => {
180+
vi.useRealTimers();
181+
});
182+
183+
// Helper function to generate random cron expressions
184+
function generateRandomCronExpression(): string {
185+
const patterns = [
186+
// Minutes
187+
"*/1 * * * *", // Every minute
188+
"*/5 * * * *", // Every 5 minutes
189+
"*/15 * * * *", // Every 15 minutes
190+
"30 * * * *", // Every hour at 30 minutes
191+
192+
// Hours
193+
"0 * * * *", // Every hour
194+
"0 */2 * * *", // Every 2 hours
195+
"0 */6 * * *", // Every 6 hours
196+
"0 9 * * *", // Daily at 9 AM
197+
"0 14 * * *", // Daily at 2 PM
198+
199+
// Days
200+
"0 9 * * 1", // Every Monday at 9 AM
201+
"0 14 * * 5", // Every Friday at 2 PM
202+
"0 10 * * 1-5", // Weekdays at 10 AM
203+
"0 0 * * 0", // Every Sunday at midnight
204+
205+
// Weekly/Monthly
206+
"0 9 * * MON", // Every Monday at 9 AM
207+
"0 12 1 * *", // First of every month at noon
208+
"0 15 15 * *", // 15th of every month at 3 PM
209+
210+
// Complex patterns
211+
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
212+
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
213+
];
214+
215+
return patterns[Math.floor(Math.random() * patterns.length)];
216+
}
217+
218+
// Helper function to generate random timestamps
219+
function generateRandomTimestamp(): Date {
220+
const now = Date.now();
221+
const possibilities = [
222+
// Recent timestamps (within last few hours)
223+
new Date(now - Math.random() * 4 * 60 * 60 * 1000),
224+
225+
// Old timestamps (days ago)
226+
new Date(now - Math.random() * 30 * 24 * 60 * 60 * 1000),
227+
228+
// Very old timestamps (months/years ago)
229+
new Date(now - Math.random() * 365 * 24 * 60 * 60 * 1000),
230+
231+
// Extremely old timestamps
232+
new Date(now - Math.random() * 10 * 365 * 24 * 60 * 60 * 1000),
233+
234+
// Edge case: exactly now
235+
new Date(now),
236+
237+
// Edge case: 1ms ago
238+
new Date(now - 1),
239+
240+
// Edge case: future timestamp (should be handled gracefully)
241+
new Date(now + Math.random() * 24 * 60 * 60 * 1000),
242+
];
243+
244+
return possibilities[Math.floor(Math.random() * possibilities.length)];
245+
}
246+
247+
test("fuzzy test: invariants should hold for random scenarios", () => {
248+
const numTests = 50;
249+
250+
for (let i = 0; i < numTests; i++) {
251+
const schedule = generateRandomCronExpression();
252+
const lastTimestamp = generateRandomTimestamp();
253+
const timezone = Math.random() > 0.7 ? "America/New_York" : null;
254+
255+
try {
256+
const startTime = performance.now();
257+
const nextRun = calculateNextScheduledTimestamp(schedule, timezone, lastTimestamp);
258+
const duration = performance.now() - startTime;
259+
260+
// Invariant 1: Result should always be a valid Date
261+
expect(nextRun).toBeInstanceOf(Date);
262+
expect(nextRun.getTime()).not.toBeNaN();
263+
264+
// Invariant 2: Result should be in the future (or equal to now if lastTimestamp was in future)
265+
if (lastTimestamp.getTime() <= Date.now()) {
266+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
267+
}
268+
269+
// Invariant 3: Performance should be reasonable (no event loop lag)
270+
expect(duration).toBeLessThan(100); // Should complete within 100ms
271+
272+
// Invariant 4: Function should be deterministic
273+
const nextRun2 = calculateNextScheduledTimestamp(schedule, timezone, lastTimestamp);
274+
expect(nextRun.getTime()).toBe(nextRun2.getTime());
275+
} catch (error) {
276+
// If there's an error, log the inputs for debugging
277+
console.error(
278+
`Failed with schedule: ${schedule}, lastTimestamp: ${lastTimestamp.toISOString()}, timezone: ${timezone}`
279+
);
280+
throw error;
281+
}
282+
}
283+
});
284+
285+
test("fuzzy test: performance under stress with frequent schedules", () => {
286+
const frequentSchedules = ["* * * * *", "*/2 * * * *", "*/5 * * * *"];
287+
288+
for (let i = 0; i < 20; i++) {
289+
const schedule = frequentSchedules[Math.floor(Math.random() * frequentSchedules.length)];
290+
291+
// Generate very old timestamps that would cause many iterations without optimization
292+
const veryOldTimestamp = new Date(Date.now() - Math.random() * 5 * 365 * 24 * 60 * 60 * 1000);
293+
294+
const startTime = performance.now();
295+
const nextRun = calculateNextScheduledTimestamp(schedule, null, veryOldTimestamp);
296+
const duration = performance.now() - startTime;
297+
298+
// Should complete quickly even with very old timestamps
299+
expect(duration).toBeLessThan(20);
300+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
301+
}
302+
});
303+
304+
test("fuzzy test: edge cases around daylight saving time", () => {
305+
// Test around DST transition dates (spring forward, fall back)
306+
const dstTestDates = [
307+
"2024-03-10T06:00:00.000Z", // Around US spring DST
308+
"2024-11-03T06:00:00.000Z", // Around US fall DST
309+
"2024-03-31T01:00:00.000Z", // Around EU spring DST
310+
"2024-10-27T01:00:00.000Z", // Around EU fall DST
311+
];
312+
313+
const timezones = ["America/New_York", "Europe/London", "America/Los_Angeles"];
314+
315+
for (let i = 0; i < 15; i++) {
316+
const schedule = generateRandomCronExpression();
317+
const testDate = dstTestDates[Math.floor(Math.random() * dstTestDates.length)];
318+
const timezone = timezones[Math.floor(Math.random() * timezones.length)];
319+
320+
vi.setSystemTime(new Date(testDate));
321+
322+
const lastTimestamp = new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000);
323+
324+
const nextRun = calculateNextScheduledTimestamp(schedule, timezone, lastTimestamp);
325+
326+
// Should handle DST transitions gracefully
327+
expect(nextRun).toBeInstanceOf(Date);
328+
expect(nextRun.getTime()).not.toBeNaN();
329+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
330+
}
331+
});
332+
333+
test("fuzzy test: boundary conditions", () => {
334+
const boundaryTests = [
335+
// End of month transitions
336+
{ time: "2024-02-29T23:59:59.000Z", schedule: "0 0 1 * *" }, // Leap year to March 1st
337+
{ time: "2024-04-30T23:59:59.000Z", schedule: "0 0 31 * *" }, // April 30th to May 31st
338+
339+
// End of year
340+
{ time: "2024-12-31T23:59:59.000Z", schedule: "0 0 1 1 *" }, // New Year
341+
342+
// Weekday transitions
343+
{ time: "2024-01-15T06:59:59.000Z", schedule: "0 7 * * MON" }, // Monday morning
344+
345+
// Hour boundaries
346+
{ time: "2024-01-15T11:59:59.000Z", schedule: "0 12 * * *" }, // Noon
347+
{ time: "2024-01-15T23:59:59.000Z", schedule: "0 0 * * *" }, // Midnight
348+
];
349+
350+
for (const test of boundaryTests) {
351+
vi.setSystemTime(new Date(test.time));
352+
353+
// Test with timestamps both before and after the boundary
354+
const beforeBoundary = new Date(Date.now() - 1000);
355+
const afterBoundary = new Date(Date.now() + 1000);
356+
357+
const nextRun1 = calculateNextScheduledTimestamp(test.schedule, null, beforeBoundary);
358+
const nextRun2 = calculateNextScheduledTimestamp(test.schedule, null, afterBoundary);
359+
360+
expect(nextRun1.getTime()).toBeGreaterThan(Date.now());
361+
expect(nextRun2.getTime()).toBeGreaterThan(Date.now());
362+
}
363+
});
364+
365+
test("fuzzy test: complex cron expressions", () => {
366+
const complexSchedules = [
367+
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
368+
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
369+
"0 0 1,15 * *", // 1st and 15th of every month
370+
"0 12 * * MON#2", // Second Monday of every month (if supported)
371+
"0 0 L * *", // Last day of month (if supported)
372+
"15,45 */2 * * *", // 15 and 45 minutes past every 2nd hour
373+
];
374+
375+
for (let i = 0; i < 30; i++) {
376+
const schedule = complexSchedules[Math.floor(Math.random() * complexSchedules.length)];
377+
const lastTimestamp = generateRandomTimestamp();
378+
379+
try {
380+
const startTime = performance.now();
381+
const nextRun = calculateNextScheduledTimestamp(schedule, null, lastTimestamp);
382+
const duration = performance.now() - startTime;
383+
384+
expect(nextRun).toBeInstanceOf(Date);
385+
expect(duration).toBeLessThan(100);
386+
387+
if (lastTimestamp.getTime() <= Date.now()) {
388+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
389+
}
390+
} catch (error) {
391+
// Some complex expressions might not be supported, that's okay
392+
if (!error.message.includes("not supported") && !error.message.includes("Invalid")) {
393+
console.error(`Unexpected error with schedule: ${schedule}`);
394+
throw error;
395+
}
396+
}
397+
}
398+
});
399+
400+
test("fuzzy test: consistency across multiple calls", () => {
401+
// Test that the function is consistent when called multiple times with same inputs
402+
for (let i = 0; i < 20; i++) {
403+
const schedule = generateRandomCronExpression();
404+
const lastTimestamp = generateRandomTimestamp();
405+
const timezone = Math.random() > 0.5 ? "UTC" : "America/New_York";
406+
407+
const results: Date[] = [];
408+
for (let j = 0; j < 5; j++) {
409+
results.push(calculateNextScheduledTimestamp(schedule, timezone, lastTimestamp));
410+
}
411+
412+
// All results should be identical
413+
for (let j = 1; j < results.length; j++) {
414+
expect(results[j].getTime()).toBe(results[0].getTime());
415+
}
416+
}
417+
});
418+
419+
test("fuzzy test: optimization threshold boundary (around 10 steps)", () => {
420+
// Test cases specifically around the 10-step optimization threshold
421+
const testCases = [
422+
{ schedule: "*/5 * * * *", minutesAgo: 50 }, // Exactly 10 steps
423+
{ schedule: "*/5 * * * *", minutesAgo: 55 }, // 11 steps (should optimize)
424+
{ schedule: "*/5 * * * *", minutesAgo: 45 }, // 9 steps (should not optimize)
425+
{ schedule: "*/10 * * * *", minutesAgo: 100 }, // Exactly 10 steps
426+
{ schedule: "*/10 * * * *", minutesAgo: 110 }, // 11 steps (should optimize)
427+
{ schedule: "*/15 * * * *", minutesAgo: 150 }, // Exactly 10 steps
428+
{ schedule: "*/1 * * * *", minutesAgo: 10 }, // Exactly 10 steps
429+
{ schedule: "*/1 * * * *", minutesAgo: 11 }, // 11 steps (should optimize)
430+
];
431+
432+
for (const testCase of testCases) {
433+
const lastTimestamp = new Date(Date.now() - testCase.minutesAgo * 60 * 1000);
434+
435+
const startTime = performance.now();
436+
const nextRun = calculateNextScheduledTimestamp(testCase.schedule, null, lastTimestamp);
437+
const duration = performance.now() - startTime;
438+
439+
// All cases should complete quickly and return valid results
440+
expect(duration).toBeLessThan(50);
441+
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
442+
expect(nextRun).toBeInstanceOf(Date);
443+
}
444+
});
445+
});

0 commit comments

Comments
 (0)