539 lines
18 KiB
C#
539 lines
18 KiB
C#
// -----------------------------------------------------------------------------
|
|
// CronNextRunPropertyTests.cs
|
|
// Sprint: SPRINT_5100_0009_0008 - Scheduler Module Test Implementation
|
|
// Task: SCHEDULER-5100-001 - Add property tests for next-run computation: cron expression → next run time deterministic
|
|
// Description: Property tests for cron expression next run time computation
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scheduler.Models.Tests.Properties;
|
|
|
|
/// <summary>
|
|
/// Property tests for cron expression next run time computation.
|
|
/// Validates:
|
|
/// - Same cron expression + reference time → same next run time (deterministic)
|
|
/// - Next run time is always in the future relative to reference time
|
|
/// - Timezone handling is consistent
|
|
/// - Edge cases (DST transitions, leap years, month boundaries)
|
|
/// </summary>
|
|
[Trait("Category", "Property")]
|
|
[Trait("Category", "Scheduler")]
|
|
[Trait("Category", "L0")]
|
|
public sealed class CronNextRunPropertyTests
|
|
{
|
|
private readonly ITestOutputHelper _output;
|
|
|
|
public CronNextRunPropertyTests(ITestOutputHelper output)
|
|
{
|
|
_output = output;
|
|
}
|
|
|
|
#region Determinism Tests
|
|
|
|
[Theory]
|
|
[InlineData("0 0 * * *")] // Daily at midnight
|
|
[InlineData("*/15 * * * *")] // Every 15 minutes
|
|
[InlineData("0 2 * * *")] // Daily at 2 AM
|
|
[InlineData("0 0 1 * *")] // First of every month
|
|
[InlineData("0 12 * * 1-5")] // Noon on weekdays
|
|
[InlineData("30 4 1,15 * *")] // 4:30 AM on 1st and 15th
|
|
public void SameCronAndTime_ProducesSameNextRun(string cronExpression)
|
|
{
|
|
// Arrange
|
|
var referenceTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
|
var timezone = TimeZoneInfo.Utc;
|
|
|
|
// Act - compute next run multiple times
|
|
var results = new List<DateTimeOffset>();
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
var nextRun = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
results.Add(nextRun);
|
|
}
|
|
|
|
// Assert
|
|
results.Distinct().Should().HaveCount(1, "same inputs should always produce same next run time");
|
|
_output.WriteLine($"Cron '{cronExpression}' at {referenceTime:O} → next run {results[0]:O}");
|
|
}
|
|
|
|
[Fact]
|
|
public void DifferentReferenceTimes_ProduceDifferentNextRuns()
|
|
{
|
|
// Arrange
|
|
var cronExpression = "0 0 * * *"; // Daily at midnight
|
|
var timezone = TimeZoneInfo.Utc;
|
|
|
|
var times = new[]
|
|
{
|
|
new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero),
|
|
new DateTimeOffset(2025, 6, 16, 12, 0, 0, TimeSpan.Zero),
|
|
new DateTimeOffset(2025, 6, 17, 12, 0, 0, TimeSpan.Zero)
|
|
};
|
|
|
|
// Act
|
|
var nextRuns = times.Select(t => ComputeNextRun(cronExpression, t, timezone)).ToList();
|
|
|
|
// Assert - all next runs should be different (one day apart)
|
|
nextRuns.Distinct().Should().HaveCount(3);
|
|
|
|
for (int i = 0; i < times.Length; i++)
|
|
{
|
|
_output.WriteLine($"Reference {times[i]:O} → Next {nextRuns[i]:O}");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Future Time Invariant Tests
|
|
|
|
[Theory]
|
|
[InlineData("* * * * *")] // Every minute
|
|
[InlineData("0 * * * *")] // Every hour
|
|
[InlineData("0 0 * * *")] // Daily
|
|
[InlineData("0 0 * * 0")] // Weekly (Sundays)
|
|
[InlineData("0 0 1 * *")] // Monthly
|
|
public void NextRun_IsAlwaysInFuture(string cronExpression)
|
|
{
|
|
// Arrange
|
|
var timezone = TimeZoneInfo.Utc;
|
|
var referenceTime = DateTimeOffset.UtcNow;
|
|
|
|
// Act
|
|
var nextRun = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
|
|
// Assert
|
|
nextRun.Should().BeAfter(referenceTime, "next run should be in the future");
|
|
_output.WriteLine($"Reference: {referenceTime:O}, Next run: {nextRun:O}");
|
|
}
|
|
|
|
[Fact]
|
|
public void NextRun_ExactMatchTime_ReturnsNextOccurrence()
|
|
{
|
|
// Arrange - reference time exactly matches a cron occurrence
|
|
var cronExpression = "0 0 * * *"; // Daily at midnight
|
|
var referenceTime = new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
|
var timezone = TimeZoneInfo.Utc;
|
|
|
|
// Act
|
|
var nextRun = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
|
|
// Assert - should return the NEXT occurrence, not the current one
|
|
nextRun.Should().BeAfter(referenceTime);
|
|
nextRun.Hour.Should().Be(0);
|
|
nextRun.Minute.Should().Be(0);
|
|
_output.WriteLine($"Exact match at {referenceTime:O} → Next run {nextRun:O}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Timezone Handling Tests
|
|
|
|
[Theory]
|
|
[InlineData("UTC")]
|
|
[InlineData("America/New_York")]
|
|
[InlineData("Europe/London")]
|
|
[InlineData("Asia/Tokyo")]
|
|
[InlineData("Australia/Sydney")]
|
|
public void DifferentTimezones_ProduceConsistentResults(string timezoneId)
|
|
{
|
|
// Skip test if timezone is not available on this system
|
|
TimeZoneInfo timezone;
|
|
try
|
|
{
|
|
timezone = TimeZoneInfo.FindSystemTimeZoneById(timezoneId);
|
|
}
|
|
catch (TimeZoneNotFoundException)
|
|
{
|
|
// Try IANA fallback
|
|
try
|
|
{
|
|
timezone = TimeZoneInfo.FindSystemTimeZoneById(ConvertToWindowsTimezone(timezoneId));
|
|
}
|
|
catch
|
|
{
|
|
_output.WriteLine($"Timezone '{timezoneId}' not available on this system, skipping");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Arrange
|
|
var cronExpression = "0 9 * * *"; // Daily at 9 AM in the specified timezone
|
|
var referenceTime = new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
// Act
|
|
var nextRun1 = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
var nextRun2 = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
|
|
// Assert
|
|
nextRun1.Should().Be(nextRun2, "same timezone should produce consistent results");
|
|
_output.WriteLine($"Timezone {timezoneId}: Next run at {nextRun1:O}");
|
|
}
|
|
|
|
[Fact]
|
|
public void LocalTimeEquivalent_AcrossTimezones()
|
|
{
|
|
// Arrange
|
|
var cronExpression = "0 12 * * *"; // Daily at noon local time
|
|
var referenceTime = new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
var utc = TimeZoneInfo.Utc;
|
|
var eastern = GetTimezoneOrDefault("Eastern Standard Time", "America/New_York");
|
|
|
|
// Act
|
|
var utcNextRun = ComputeNextRun(cronExpression, referenceTime, utc);
|
|
var easternNextRun = ComputeNextRun(cronExpression, referenceTime, eastern);
|
|
|
|
// Assert - both should be at noon local time (different UTC times)
|
|
utcNextRun.UtcDateTime.Hour.Should().Be(12);
|
|
|
|
// Eastern should be noon Eastern, which is 16:00 or 17:00 UTC depending on DST
|
|
var easternLocal = TimeZoneInfo.ConvertTime(easternNextRun, eastern);
|
|
easternLocal.Hour.Should().Be(12);
|
|
|
|
_output.WriteLine($"UTC next run: {utcNextRun:O}");
|
|
_output.WriteLine($"Eastern next run: {easternNextRun:O} (local: {easternLocal:O})");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DST Transition Tests
|
|
|
|
[Fact]
|
|
public void DstSpringForward_HandlesSkippedHour()
|
|
{
|
|
// Arrange - 2 AM doesn't exist during spring forward (2025-03-09 in US)
|
|
var cronExpression = "0 2 * * *"; // Daily at 2 AM
|
|
var referenceTime = new DateTimeOffset(2025, 3, 8, 0, 0, 0, TimeSpan.FromHours(-5)); // March 8, before DST
|
|
var eastern = GetTimezoneOrDefault("Eastern Standard Time", "America/New_York");
|
|
|
|
// Act
|
|
var nextRun = ComputeNextRun(cronExpression, referenceTime, eastern);
|
|
|
|
// Assert - should handle the skipped hour gracefully
|
|
nextRun.Should().BeAfter(referenceTime);
|
|
_output.WriteLine($"DST spring forward: Reference {referenceTime:O} → Next {nextRun:O}");
|
|
}
|
|
|
|
[Fact]
|
|
public void DstFallBack_HandlesRepeatedHour()
|
|
{
|
|
// Arrange - 1 AM occurs twice during fall back (2025-11-02 in US)
|
|
var cronExpression = "0 1 * * *"; // Daily at 1 AM
|
|
var referenceTime = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.FromHours(-4)); // Nov 1, before fallback
|
|
var eastern = GetTimezoneOrDefault("Eastern Standard Time", "America/New_York");
|
|
|
|
// Act
|
|
var nextRun1 = ComputeNextRun(cronExpression, referenceTime, eastern);
|
|
var nextRun2 = ComputeNextRun(cronExpression, referenceTime, eastern);
|
|
|
|
// Assert - should be deterministic even with ambiguous times
|
|
nextRun1.Should().Be(nextRun2);
|
|
_output.WriteLine($"DST fall back: Reference {referenceTime:O} → Next {nextRun1:O}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Edge Case Tests
|
|
|
|
[Fact]
|
|
public void LeapYear_FebruarySchedule()
|
|
{
|
|
// Arrange
|
|
var cronExpression = "0 0 29 2 *"; // February 29th (leap day)
|
|
var referenceTime = new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero); // 2024 is a leap year
|
|
var timezone = TimeZoneInfo.Utc;
|
|
|
|
// Act
|
|
var nextRun = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
|
|
// Assert
|
|
nextRun.Month.Should().Be(2);
|
|
nextRun.Day.Should().Be(29);
|
|
_output.WriteLine($"Leap year: {nextRun:O}");
|
|
}
|
|
|
|
[Fact]
|
|
public void EndOfMonth_VariableDays()
|
|
{
|
|
// Arrange - 31st only exists in some months
|
|
var cronExpression = "0 0 31 * *"; // 31st of every month
|
|
var referenceTime = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero); // Feb has no 31st
|
|
var timezone = TimeZoneInfo.Utc;
|
|
|
|
// Act
|
|
var nextRun = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
|
|
// Assert - should skip to next month with 31 days (March)
|
|
nextRun.Month.Should().Be(3);
|
|
nextRun.Day.Should().Be(31);
|
|
_output.WriteLine($"End of month: {nextRun:O}");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("0 0 1 1 *")] // January 1st
|
|
[InlineData("0 0 25 12 *")] // December 25th
|
|
[InlineData("0 0 1 7 *")] // July 1st
|
|
public void YearlySchedules_Deterministic(string cronExpression)
|
|
{
|
|
// Arrange
|
|
var referenceTime = new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero);
|
|
var timezone = TimeZoneInfo.Utc;
|
|
|
|
// Act
|
|
var results = new List<DateTimeOffset>();
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
results.Add(ComputeNextRun(cronExpression, referenceTime, timezone));
|
|
}
|
|
|
|
// Assert
|
|
results.Distinct().Should().HaveCount(1);
|
|
_output.WriteLine($"Yearly '{cronExpression}' → {results[0]:O}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Complex Expression Tests
|
|
|
|
[Theory]
|
|
[InlineData("0 0,12 * * *")] // Midnight and noon
|
|
[InlineData("0 */6 * * *")] // Every 6 hours
|
|
[InlineData("15,45 * * * *")] // At 15 and 45 minutes past each hour
|
|
[InlineData("0 9-17 * * 1-5")] // 9 AM to 5 PM on weekdays
|
|
[InlineData("0 0 L * *")] // Last day of month (if supported)
|
|
public void ComplexExpressions_Deterministic(string cronExpression)
|
|
{
|
|
// Arrange
|
|
var referenceTime = new DateTimeOffset(2025, 6, 15, 10, 0, 0, TimeSpan.Zero);
|
|
var timezone = TimeZoneInfo.Utc;
|
|
|
|
// Act
|
|
DateTimeOffset nextRun;
|
|
try
|
|
{
|
|
nextRun = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
// Some complex expressions may not be supported
|
|
_output.WriteLine($"Expression '{cronExpression}' not supported: {ex.Message}");
|
|
return;
|
|
}
|
|
|
|
var nextRun2 = ComputeNextRun(cronExpression, referenceTime, timezone);
|
|
|
|
// Assert
|
|
nextRun.Should().Be(nextRun2);
|
|
_output.WriteLine($"Complex '{cronExpression}' → {nextRun:O}");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Sequence Tests
|
|
|
|
[Fact]
|
|
public void NextRunSequence_IsMonotonicallyIncreasing()
|
|
{
|
|
// Arrange
|
|
var cronExpression = "*/5 * * * *"; // Every 5 minutes
|
|
var timezone = TimeZoneInfo.Utc;
|
|
var currentTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
// Act - compute a sequence of next runs
|
|
var sequence = new List<DateTimeOffset>();
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
var nextRun = ComputeNextRun(cronExpression, currentTime, timezone);
|
|
sequence.Add(nextRun);
|
|
currentTime = nextRun;
|
|
}
|
|
|
|
// Assert - each subsequent run should be after the previous
|
|
for (int i = 1; i < sequence.Count; i++)
|
|
{
|
|
sequence[i].Should().BeAfter(sequence[i - 1],
|
|
$"run {i} should be after run {i - 1}");
|
|
}
|
|
|
|
_output.WriteLine($"Sequence ({sequence.Count} runs):");
|
|
foreach (var run in sequence.Take(5))
|
|
{
|
|
_output.WriteLine($" {run:O}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DailySequence_SpacedCorrectly()
|
|
{
|
|
// Arrange
|
|
var cronExpression = "0 0 * * *"; // Daily at midnight
|
|
var timezone = TimeZoneInfo.Utc;
|
|
var currentTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
// Act
|
|
var sequence = new List<DateTimeOffset>();
|
|
for (int i = 0; i < 7; i++)
|
|
{
|
|
var nextRun = ComputeNextRun(cronExpression, currentTime, timezone);
|
|
sequence.Add(nextRun);
|
|
currentTime = nextRun;
|
|
}
|
|
|
|
// Assert - each run should be exactly 24 hours apart
|
|
for (int i = 1; i < sequence.Count; i++)
|
|
{
|
|
var gap = sequence[i] - sequence[i - 1];
|
|
gap.Should().Be(TimeSpan.FromHours(24),
|
|
$"daily runs should be 24 hours apart");
|
|
}
|
|
|
|
_output.WriteLine("Daily sequence spacing verified");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
/// <summary>
|
|
/// Computes the next run time for a cron expression.
|
|
/// Uses a simplified implementation for testing purposes.
|
|
/// In production, this would use the actual scheduler implementation.
|
|
/// </summary>
|
|
private static DateTimeOffset ComputeNextRun(
|
|
string cronExpression,
|
|
DateTimeOffset referenceTime,
|
|
TimeZoneInfo timezone)
|
|
{
|
|
// Validate cron expression (inline - Validation is internal)
|
|
if (string.IsNullOrWhiteSpace(cronExpression))
|
|
throw new ArgumentException("Cron expression cannot be null or empty", nameof(cronExpression));
|
|
|
|
// Convert reference time to local timezone
|
|
var localTime = TimeZoneInfo.ConvertTime(referenceTime, timezone);
|
|
|
|
// Parse cron expression parts
|
|
var parts = cronExpression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length < 5)
|
|
{
|
|
throw new ArgumentException("Invalid cron expression format");
|
|
}
|
|
|
|
if (parts.Any(part => part.Contains('L', StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
throw new ArgumentException("Cron expressions with 'L' are not supported by the test helper.");
|
|
}
|
|
|
|
// Simplified next-run computation (deterministic)
|
|
// This is a simplified implementation for testing - real implementation uses Cronos or similar
|
|
var candidate = localTime.AddMinutes(1);
|
|
candidate = new DateTimeOffset(
|
|
candidate.Year, candidate.Month, candidate.Day,
|
|
candidate.Hour, candidate.Minute, 0, candidate.Offset);
|
|
|
|
// Simple iteration to find next match (limited for testing)
|
|
for (int i = 0; i < 525600; i++) // Max 1 year of minutes
|
|
{
|
|
if (MatchesCron(parts, candidate))
|
|
{
|
|
return TimeZoneInfo.ConvertTime(candidate, TimeZoneInfo.Utc);
|
|
}
|
|
candidate = candidate.AddMinutes(1);
|
|
}
|
|
|
|
throw new InvalidOperationException("Could not find next run time within 1 year");
|
|
}
|
|
|
|
private static bool MatchesCron(string[] parts, DateTimeOffset time)
|
|
{
|
|
// Parts: minute, hour, day-of-month, month, day-of-week
|
|
var minute = time.Minute;
|
|
var hour = time.Hour;
|
|
var dayOfMonth = time.Day;
|
|
var month = time.Month;
|
|
var dayOfWeek = (int)time.DayOfWeek;
|
|
|
|
return MatchesCronField(parts[0], minute, 0, 59) &&
|
|
MatchesCronField(parts[1], hour, 0, 23) &&
|
|
MatchesCronField(parts[2], dayOfMonth, 1, 31) &&
|
|
MatchesCronField(parts[3], month, 1, 12) &&
|
|
MatchesCronField(parts[4], dayOfWeek, 0, 6);
|
|
}
|
|
|
|
private static bool MatchesCronField(string field, int value, int min, int max)
|
|
{
|
|
if (field == "*") return true;
|
|
|
|
// Handle step values (*/n)
|
|
if (field.StartsWith("*/"))
|
|
{
|
|
if (int.TryParse(field.AsSpan(2), out var step))
|
|
{
|
|
return value % step == 0;
|
|
}
|
|
}
|
|
|
|
// Handle ranges (n-m)
|
|
if (field.Contains('-') && !field.Contains(','))
|
|
{
|
|
var rangeParts = field.Split('-');
|
|
if (rangeParts.Length == 2 &&
|
|
int.TryParse(rangeParts[0], out var start) &&
|
|
int.TryParse(rangeParts[1], out var end))
|
|
{
|
|
return value >= start && value <= end;
|
|
}
|
|
}
|
|
|
|
// Handle lists (n,m,o)
|
|
if (field.Contains(','))
|
|
{
|
|
return field.Split(',')
|
|
.Select(f => f.Trim())
|
|
.Any(f => int.TryParse(f, out var v) && v == value);
|
|
}
|
|
|
|
// Handle single values
|
|
if (int.TryParse(field, out var single))
|
|
{
|
|
return single == value;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static TimeZoneInfo GetTimezoneOrDefault(string windowsId, string ianaId)
|
|
{
|
|
try
|
|
{
|
|
return TimeZoneInfo.FindSystemTimeZoneById(windowsId);
|
|
}
|
|
catch
|
|
{
|
|
try
|
|
{
|
|
return TimeZoneInfo.FindSystemTimeZoneById(ianaId);
|
|
}
|
|
catch
|
|
{
|
|
return TimeZoneInfo.Utc;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string ConvertToWindowsTimezone(string ianaId)
|
|
{
|
|
return ianaId switch
|
|
{
|
|
"America/New_York" => "Eastern Standard Time",
|
|
"Europe/London" => "GMT Standard Time",
|
|
"Asia/Tokyo" => "Tokyo Standard Time",
|
|
"Australia/Sydney" => "AUS Eastern Standard Time",
|
|
_ => ianaId
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|