diff --git a/README.md b/README.md index 6906d3a..8371003 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,14 @@ public class CalendarService(IRecurrenceEngine engine) ```csharp // Create a weekly recurring meeting +// Note: RecurrenceEndTime is automatically extracted from the RRule UNTIL clause await engine.CreateRecurrenceAsync(new RecurrenceCreate { Organization = "tenant1", ResourcePath = "user123/calendar", Type = "meeting", - StartTimeUtc = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), + StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time Duration = TimeSpan.FromHours(1), - RecurrenceEndTimeUtc = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z", TimeZone = "America/New_York" }); diff --git a/src/RecurringThings.MongoDB/README.md b/src/RecurringThings.MongoDB/README.md index c83da8f..fbae13b 100644 --- a/src/RecurringThings.MongoDB/README.md +++ b/src/RecurringThings.MongoDB/README.md @@ -74,14 +74,14 @@ public class CalendarService(IRecurrenceEngine engine) { public async Task CreateWeeklyMeetingAsync() { + // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause var recurrence = await engine.CreateRecurrenceAsync(new RecurrenceCreate { Organization = "tenant1", ResourcePath = "user123/calendar", Type = "meeting", - StartTimeUtc = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), + StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time Duration = TimeSpan.FromHours(1), - RecurrenceEndTimeUtc = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z", TimeZone = "America/New_York", Extensions = new Dictionary @@ -164,6 +164,6 @@ dotnet test --filter 'Category=Integration' ## Limitations - MongoDB transactions require replica set (not available on standalone instances) -- All DateTime values must be UTC +- DateTime values can be UTC or Local (`DateTimeKind.Unspecified` is not allowed) - RRule must use UNTIL (COUNT is not supported) - UNTIL must have UTC suffix (Z) diff --git a/src/RecurringThings.PostgreSQL/README.md b/src/RecurringThings.PostgreSQL/README.md index 1400404..b49e3ab 100644 --- a/src/RecurringThings.PostgreSQL/README.md +++ b/src/RecurringThings.PostgreSQL/README.md @@ -84,14 +84,14 @@ public class CalendarService(IRecurrenceEngine engine) { public async Task CreateWeeklyMeetingAsync() { + // RecurrenceEndTime is automatically extracted from the RRule UNTIL clause var recurrence = await engine.CreateRecurrenceAsync(new RecurrenceCreate { Organization = "tenant1", ResourcePath = "user123/calendar", Type = "meeting", - StartTimeUtc = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), + StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time Duration = TimeSpan.FromHours(1), - RecurrenceEndTimeUtc = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z", TimeZone = "America/New_York", Extensions = new Dictionary @@ -174,6 +174,6 @@ dotnet test --filter 'Category=Integration' ## Limitations - Database must exist before running the application (schema is auto-created) -- All DateTime values must be UTC +- DateTime values can be UTC or Local (`DateTimeKind.Unspecified` is not allowed) - RRule must use UNTIL (COUNT is not supported) - UNTIL must have UTC suffix (Z) diff --git a/src/RecurringThings/Engine/IRecurrenceEngine.cs b/src/RecurringThings/Engine/IRecurrenceEngine.cs index d8639a8..d6f9c4e 100644 --- a/src/RecurringThings/Engine/IRecurrenceEngine.cs +++ b/src/RecurringThings/Engine/IRecurrenceEngine.cs @@ -88,7 +88,6 @@ IAsyncEnumerable GetAsync( /// Type = "appointment", /// StartTime = DateTime.UtcNow, // Or DateTime.Now for local time /// Duration = TimeSpan.FromHours(1), - /// RecurrenceEndTime = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc), /// RRule = "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z", /// TimeZone = "America/New_York" /// }); @@ -164,7 +163,7 @@ Task CreateOccurrenceAsync( /// Thrown when is null. /// /// Thrown when attempting to modify immutable fields (Organization, ResourcePath, Type, TimeZone, - /// or recurrence-specific fields like RRule, StartTime, RecurrenceEndTime). + /// or recurrence-specific fields like RRule and StartTime). /// /// /// Thrown when the underlying entity (recurrence, occurrence, or override) is not found. diff --git a/src/RecurringThings/Engine/RecurrenceEngine.cs b/src/RecurringThings/Engine/RecurrenceEngine.cs index c5b6a54..2856c86 100644 --- a/src/RecurringThings/Engine/RecurrenceEngine.cs +++ b/src/RecurringThings/Engine/RecurrenceEngine.cs @@ -231,9 +231,11 @@ public async Task CreateRecurrenceAsync( var validationResult = await _recurrenceCreateValidator.ValidateAsync(request, cancellationToken).ConfigureAwait(false); validationResult.ThrowIfInvalid(); - // Convert input times to UTC if they're local + // Convert input time to UTC if it's local var startTimeUtc = ConvertToUtc(request.StartTime, request.TimeZone); - var recurrenceEndTimeUtc = ConvertToUtc(request.RecurrenceEndTime, request.TimeZone); + + // Extract RecurrenceEndTime from RRule UNTIL clause + var recurrenceEndTimeUtc = ExtractUntilFromRRule(request.RRule); // Create the recurrence entity var recurrence = new Recurrence @@ -553,8 +555,7 @@ private async Task UpdateRecurrenceAsync( RecurrenceId = updated.Id, RecurrenceDetails = new RecurrenceDetails { - RRule = updated.RRule, - RecurrenceEndTime = updated.RecurrenceEndTime + RRule = updated.RRule } }; } @@ -594,7 +595,7 @@ private static void ValidateImmutableFields(CalendarEntry entry, Recurrence exis "Cannot modify StartTime on a recurrence. This field is immutable after creation."); } - // Only validate RRule and RecurrenceEndTime if RecurrenceDetails is provided + // Only validate RRule if RecurrenceDetails is provided if (entry.RecurrenceDetails is not null) { if (entry.RecurrenceDetails.RRule != existing.RRule) @@ -602,12 +603,6 @@ private static void ValidateImmutableFields(CalendarEntry entry, Recurrence exis throw new InvalidOperationException( "Cannot modify RRule. This field is immutable after creation."); } - - if (entry.RecurrenceDetails.RecurrenceEndTime != existing.RecurrenceEndTime) - { - throw new InvalidOperationException( - "Cannot modify RecurrenceEndTime. This field is immutable after creation."); - } } } @@ -982,6 +977,25 @@ await _overrideRepository.DeleteAsync( cancellationToken).ConfigureAwait(false); } + /// + /// Extracts the UNTIL value from an RRule string and returns it as a UTC DateTime. + /// + /// The RRule string containing an UNTIL clause. + /// The UNTIL value as a UTC DateTime. + /// Thrown when the RRule does not contain a valid UNTIL clause. + private static DateTime ExtractUntilFromRRule(string rrule) + { + var pattern = new RecurrencePattern(rrule); + + if (pattern.Until is null) + { + throw new ArgumentException("RRule must contain UNTIL clause.", nameof(rrule)); + } + + // Ical.Net parses UNTIL as UTC when the Z suffix is present + return DateTime.SpecifyKind(pattern.Until.Value, DateTimeKind.Utc); + } + /// /// Converts a DateTime to UTC. If already UTC, returns as-is. If Local, converts using the specified timezone. /// diff --git a/src/RecurringThings/Models/RecurrenceCreate.cs b/src/RecurringThings/Models/RecurrenceCreate.cs index 2a4ec8e..c88eb9f 100644 --- a/src/RecurringThings/Models/RecurrenceCreate.cs +++ b/src/RecurringThings/Models/RecurrenceCreate.cs @@ -9,7 +9,8 @@ namespace RecurringThings.Models; /// /// DateTime values can be provided in UTC or Local time (Unspecified is not allowed). /// Local times are converted to UTC internally using the specified . -/// The RRule UNTIL value must be in UTC (Z suffix) and must match when converted to UTC. +/// The RRule UNTIL value must be in UTC (Z suffix). The recurrence end time is automatically +/// extracted from the RRule UNTIL clause. /// public sealed class RecurrenceCreate { @@ -58,22 +59,12 @@ public sealed class RecurrenceCreate /// public required TimeSpan Duration { get; init; } - /// - /// Gets the timestamp when the recurrence series ends. - /// - /// - /// Can be provided in UTC or Local time (Unspecified is not allowed). - /// Local times are converted to UTC internally using . - /// Must match the UNTIL value in when converted to UTC. - /// Used for efficient query filtering without full virtualization. - /// - public required DateTime RecurrenceEndTime { get; init; } - /// /// Gets the RFC 5545 recurrence rule defining the pattern. /// /// /// Must use UNTIL (in UTC with Z suffix); COUNT is not supported. + /// The recurrence end time is automatically extracted from the UNTIL clause. /// Maximum length is 2000 characters. /// Example: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z" /// diff --git a/src/RecurringThings/Models/RecurrenceDetails.cs b/src/RecurringThings/Models/RecurrenceDetails.cs index 711bae4..5bca164 100644 --- a/src/RecurringThings/Models/RecurrenceDetails.cs +++ b/src/RecurringThings/Models/RecurrenceDetails.cs @@ -1,7 +1,5 @@ namespace RecurringThings.Models; -using System; - /// /// Contains recurrence-specific details when a represents a recurrence pattern. /// @@ -11,19 +9,12 @@ namespace RecurringThings.Models; /// public sealed class RecurrenceDetails { - /// - /// Gets or sets the UTC timestamp when the recurrence series ends. - /// - /// - /// Matches the UNTIL value in the RRule. - /// - public DateTime RecurrenceEndTime { get; set; } - /// /// Gets or sets the RFC 5545 recurrence rule defining the pattern. /// /// - /// Example: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z" + /// Example: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z" + /// The recurrence end time can be extracted by parsing the UNTIL clause from this RRule. /// public required string RRule { get; set; } } diff --git a/src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs b/src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs index 910c2c4..eceb1bc 100644 --- a/src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs +++ b/src/RecurringThings/Validation/Validators/RecurrenceCreateValidator.cs @@ -1,9 +1,8 @@ namespace RecurringThings.Validation.Validators; -using System; using System.Text.RegularExpressions; using FluentValidation; -using NodaTime; +using Ical.Net.DataTypes; using RecurringThings.Models; /// @@ -52,24 +51,18 @@ public RecurrenceCreateValidator() .WithMessage($"RRule must be at least {ValidationConstants.MinRRuleLength} character(s).") .MaximumLength(ValidationConstants.MaxRRuleLength) .WithMessage($"RRule must not exceed {ValidationConstants.MaxRRuleLength} characters. Actual length: {{TotalLength}}.") - .Must(rrule => !CountRegex().IsMatch(rrule)) + .Must(BeValidRRule) + .WithMessage("RRule is not a valid RFC 5545 recurrence rule.") + .Must(MustNotHaveCount) .WithMessage("RRule COUNT is not supported. Use UNTIL instead.") - .Must(rrule => UntilRegex().IsMatch(rrule)) + .Must(MustHaveUntil) .WithMessage("RRule must contain UNTIL. COUNT is not supported.") .Must(HasUtcUntil) .WithMessage(GetUntilNotUtcMessage); - // Cross-property validation: RRule UNTIL must match RecurrenceEndTime when converted to UTC - RuleFor(x => x.RRule) - .Must((request, rrule) => ValidateUntilMatchesEndTime(rrule, request.RecurrenceEndTime, request.TimeZone)) - .WithMessage(x => $"RecurrenceEndTime ({x.RecurrenceEndTime:O}) must match RRule UNTIL when converted to UTC."); - RuleFor(x => x.StartTime) .MustNotBeUnspecified(); - RuleFor(x => x.RecurrenceEndTime) - .MustNotBeUnspecified(); - RuleFor(x => x.Duration) .MustBePositive(); @@ -77,99 +70,107 @@ public RecurrenceCreateValidator() .ValidExtensions(); } - private static bool HasUtcUntil(string rrule) + /// + /// Validates that the RRule can be parsed by Ical.Net. + /// + private static bool BeValidRRule(string rrule) { - var untilMatch = UntilRegex().Match(rrule); - if (!untilMatch.Success) + if (string.IsNullOrEmpty(rrule)) { - return true; // Will be caught by the UNTIL required rule + return true; // Will be caught by NotNull/MinimumLength rules } - var untilValue = untilMatch.Groups["until"].Value; - return untilValue.EndsWith('Z'); - } - - private static string GetUntilNotUtcMessage(RecurrenceCreate request) - { - var untilMatch = UntilRegex().Match(request.RRule); - if (!untilMatch.Success) + try { - return "RRule must contain UNTIL."; + _ = new RecurrencePattern(rrule); + return true; + } + catch + { + return false; } - - var untilValue = untilMatch.Groups["until"].Value; - return $"RRule UNTIL must be in UTC (must end with 'Z'). Found: {untilValue}"; } - private static bool ValidateUntilMatchesEndTime(string rrule, DateTime recurrenceEndTime, string? timeZone) + /// + /// Validates that the RRule does not use COUNT. + /// + private static bool MustNotHaveCount(string rrule) { - var untilMatch = UntilRegex().Match(rrule); - if (!untilMatch.Success) + if (string.IsNullOrEmpty(rrule)) { - return true; // Will be caught by the UNTIL required rule + return true; // Will be caught by NotNull/MinimumLength rules } - var untilValue = untilMatch.Groups["until"].Value; - - if (!TryParseICalDateTime(untilValue, out var parsedUntil)) + try { - return false; - } + var pattern = new RecurrencePattern(rrule); - // Convert recurrenceEndTime to UTC if it's Local - var endTimeUtc = recurrenceEndTime; - if (recurrenceEndTime.Kind == DateTimeKind.Local && !string.IsNullOrEmpty(timeZone)) + // COUNT must not be specified (null when not set in Ical.Net) + return !pattern.Count.HasValue; + } + catch { - var tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(timeZone); - if (tz is not null) - { - var localDateTime = LocalDateTime.FromDateTime(recurrenceEndTime); - var zonedDateTime = localDateTime.InZoneLeniently(tz); - endTimeUtc = zonedDateTime.ToDateTimeUtc(); - } + return true; // Will be caught by BeValidRRule } - - // Allow 1 second tolerance for comparison due to potential rounding - var difference = Math.Abs((parsedUntil - endTimeUtc).TotalSeconds); - return difference <= 1; } - private static bool TryParseICalDateTime(string value, out DateTime result) + /// + /// Validates that the RRule contains UNTIL. + /// + private static bool MustHaveUntil(string rrule) { - result = default; + if (string.IsNullOrEmpty(rrule)) + { + return true; // Will be caught by NotNull/MinimumLength rules + } - // Remove 'Z' suffix for parsing - var toParse = value.TrimEnd('Z'); + try + { + var pattern = new RecurrencePattern(rrule); - // iCalendar format: YYYYMMDDTHHMMSS - if (toParse.Length != 15 || toParse[8] != 'T') + // UNTIL must be specified (null when not set) + return pattern.Until is not null; + } + catch { - return false; + return true; // Will be caught by BeValidRRule } + } - if (!int.TryParse(toParse.AsSpan(0, 4), out var year) || - !int.TryParse(toParse.AsSpan(4, 2), out var month) || - !int.TryParse(toParse.AsSpan(6, 2), out var day) || - !int.TryParse(toParse.AsSpan(9, 2), out var hour) || - !int.TryParse(toParse.AsSpan(11, 2), out var minute) || - !int.TryParse(toParse.AsSpan(13, 2), out var second)) + /// + /// Validates that the UNTIL value in the RRule is in UTC (ends with Z). + /// + private static bool HasUtcUntil(string rrule) + { + if (string.IsNullOrEmpty(rrule)) { - return false; + return true; // Will be caught by NotNull/MinimumLength rules } - try + var untilMatch = UntilRegex().Match(rrule); + if (!untilMatch.Success) { - result = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc); - return true; + return true; // Will be caught by HaveUntilNotCount } - catch (ArgumentOutOfRangeException) + + var untilValue = untilMatch.Groups["until"].Value; + return untilValue.EndsWith('Z'); + } + + /// + /// Gets the error message when UNTIL is not in UTC format. + /// + private static string GetUntilNotUtcMessage(RecurrenceCreate request) + { + var untilMatch = UntilRegex().Match(request.RRule); + if (!untilMatch.Success) { - return false; + return "RRule must contain UNTIL."; } - } - [GeneratedRegex(@"(?:^|;)COUNT\s*=", RegexOptions.IgnoreCase)] - private static partial Regex CountRegex(); + var untilValue = untilMatch.Groups["until"].Value; + return $"RRule UNTIL must be in UTC (must end with 'Z'). Found: {untilValue}"; + } [GeneratedRegex(@"(?:^|;)UNTIL\s*=\s*(?[^;]+)", RegexOptions.IgnoreCase)] private static partial Regex UntilRegex(); diff --git a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs index ac5afa0..4a27d83 100644 --- a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs +++ b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs @@ -79,7 +79,8 @@ public async Task CreateRecurrenceAsync_WithValidRequest_ReturnsRecurrenceWithGe result.Type.Should().Be(request.Type); result.StartTime.Should().Be(request.StartTime); result.Duration.Should().Be(request.Duration); - result.RecurrenceEndTime.Should().Be(request.RecurrenceEndTime); + // RecurrenceEndTime is extracted from RRule UNTIL clause + result.RecurrenceEndTime.Should().Be(new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc)); result.RRule.Should().Be(request.RRule); result.TimeZone.Should().Be(request.TimeZone); result.Extensions.Should().BeEquivalentTo(request.Extensions); @@ -934,7 +935,6 @@ private static RecurrenceCreate CreateValidRecurrenceRequest( Type = type ?? TestType, StartTime = new DateTime(2024, 1, 1, 9, 0, 0, DateTimeKind.Utc), Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = rrule ?? "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z", TimeZone = timeZone ?? TestTimeZone, Extensions = new Dictionary { ["key"] = "value" } diff --git a/tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs b/tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs index 99c9372..7d7e7b1 100644 --- a/tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs +++ b/tests/RecurringThings.Tests/Validation/Validators/RecurrenceCreateValidatorTests.cs @@ -28,7 +28,6 @@ public void Validate_WhenValid_ShouldNotHaveErrors() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -51,7 +50,6 @@ public void Validate_WhenValidWithExtensions_ShouldNotHaveErrors() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York", Extensions = new Dictionary @@ -83,7 +81,6 @@ public void Validate_WhenOrganizationNull_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -106,7 +103,6 @@ public void Validate_WhenOrganizationEmpty_ShouldNotHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -129,7 +125,6 @@ public void Validate_WhenOrganizationExceedsMaxLength_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -157,7 +152,6 @@ public void Validate_WhenTypeNull_ShouldHaveError() Type = null!, StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -180,7 +174,6 @@ public void Validate_WhenTypeEmpty_ShouldHaveError() Type = "", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -207,7 +200,6 @@ public void Validate_WhenTimeZoneInvalidIana_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "Eastern Standard Time" // Windows time zone, not IANA }; @@ -235,7 +227,6 @@ public void Validate_WhenRRuleContainsCount_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;COUNT=10", TimeZone = "America/New_York" }; @@ -259,7 +250,6 @@ public void Validate_WhenRRuleMissingUntil_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;BYDAY=MO,TU,WE", TimeZone = "America/New_York" }; @@ -283,7 +273,6 @@ public void Validate_WhenRRuleUntilNotUtc_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959", // Missing Z TimeZone = "America/New_York" }; @@ -295,29 +284,6 @@ public void Validate_WhenRRuleUntilNotUtc_ShouldHaveError() result.ShouldHaveValidationErrorFor(x => x.RRule); } - [Fact] - public void Validate_WhenRRuleUntilMismatchesEndTime_ShouldHaveError() - { - // Arrange - var request = new RecurrenceCreate - { - Organization = "org", - ResourcePath = "path", - Type = "appointment", - StartTime = DateTime.UtcNow, - Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), - RRule = "FREQ=DAILY;UNTIL=20251231T235959Z", // Different year - TimeZone = "America/New_York" - }; - - // Act - var result = _validator.TestValidate(request); - - // Assert - result.ShouldHaveValidationErrorFor(x => x.RRule); - } - #endregion #region StartTime Tests @@ -333,7 +299,6 @@ public void Validate_WhenStartTimeUtc_ShouldNotHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -356,7 +321,6 @@ public void Validate_WhenStartTimeLocal_ShouldNotHaveError() Type = "appointment", StartTime = DateTime.Now, // Local time Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -379,7 +343,6 @@ public void Validate_WhenStartTimeUnspecified_ShouldHaveError() Type = "appointment", StartTime = new DateTime(2025, 6, 1, 9, 0, 0, DateTimeKind.Unspecified), Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -407,7 +370,6 @@ public void Validate_WhenDurationZero_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.Zero, - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York" }; @@ -435,7 +397,6 @@ public void Validate_WhenExtensionKeyEmpty_ShouldHaveError() Type = "appointment", StartTime = DateTime.UtcNow, Duration = TimeSpan.FromHours(1), - RecurrenceEndTime = new DateTime(2026, 12, 31, 23, 59, 59, DateTimeKind.Utc), RRule = "FREQ=DAILY;UNTIL=20261231T235959Z", TimeZone = "America/New_York", Extensions = new Dictionary