diff --git a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs index ce9bb4f..67ea962 100644 --- a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs +++ b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs @@ -5,6 +5,7 @@ namespace RecurringThings.MongoDB.Documents; using global::MongoDB.Bson; using global::MongoDB.Bson.Serialization.Attributes; using RecurringThings.Domain; +using RecurringThings.Options; /// /// MongoDB document model for storing recurrences, occurrences, exceptions, and overrides in a single collection. @@ -123,6 +124,22 @@ public sealed class RecurringThingDocument [BsonIgnoreIfNull] public string? RRule { get; set; } + /// + /// Gets or sets the strategy for out-of-bounds monthly days. + /// + /// + /// + /// Present only on recurrences with monthly patterns that have out-of-bounds days. + /// + /// + /// Values: "skip" or "clamp". Null when not applicable (non-monthly patterns, + /// monthly patterns with day <= 28, or patterns where no months are affected). + /// + /// + [BsonElement("monthDayBehavior")] + [BsonIgnoreIfNull] + public string? MonthDayBehavior { get; set; } + /// /// Gets or sets the parent recurrence identifier. /// @@ -232,7 +249,8 @@ public static Recurrence ToRecurrence(RecurringThingDocument document) RecurrenceEndTime = document.RecurrenceEndTime!.Value, RRule = document.RRule!, TimeZone = document.TimeZone!, - Extensions = document.Extensions + Extensions = document.Extensions, + MonthDayBehavior = ParseMonthDayBehavior(document.MonthDayBehavior) }; } @@ -352,7 +370,8 @@ public static RecurringThingDocument FromRecurrence(Recurrence recurrence) RecurrenceEndTime = recurrence.RecurrenceEndTime, RRule = recurrence.RRule, TimeZone = recurrence.TimeZone, - Extensions = recurrence.Extensions + Extensions = recurrence.Extensions, + MonthDayBehavior = SerializeMonthDayBehavior(recurrence.MonthDayBehavior) // EndTime is NOT set for recurrences }; } @@ -427,4 +446,34 @@ public static RecurringThingDocument FromOccurrenceOverride(OccurrenceOverride @ Extensions = @override.Extensions }; } + + /// + /// Parses a string value to . + /// + /// The string value ("skip" or "clamp"). + /// The parsed strategy, or null if the value is null or empty. + private static MonthDayOutOfBoundsStrategy? ParseMonthDayBehavior(string? value) + { + return value switch + { + "skip" => MonthDayOutOfBoundsStrategy.Skip, + "clamp" => MonthDayOutOfBoundsStrategy.Clamp, + _ => null + }; + } + + /// + /// Serializes a to a string value. + /// + /// The strategy to serialize. + /// The string value ("skip" or "clamp"), or null if the strategy is null. + private static string? SerializeMonthDayBehavior(MonthDayOutOfBoundsStrategy? strategy) + { + return strategy switch + { + MonthDayOutOfBoundsStrategy.Skip => "skip", + MonthDayOutOfBoundsStrategy.Clamp => "clamp", + _ => null + }; + } } diff --git a/src/RecurringThings.MongoDB/README.md b/src/RecurringThings.MongoDB/README.md index cad0729..fae7e05 100644 --- a/src/RecurringThings.MongoDB/README.md +++ b/src/RecurringThings.MongoDB/README.md @@ -5,14 +5,12 @@ MongoDB persistence provider for [RecurringThings](../../README.md). ## Installation - ```bash dotnet add package RecurringThings dotnet add package RecurringThings.MongoDB ``` ## Configuration - ```csharp using RecurringThings.Configuration; using RecurringThings.MongoDB.Configuration; @@ -26,11 +24,14 @@ services.AddRecurringThings(builder => })); ``` +## Sharding Considerations + +All queries issued by this provider filter by `Organization` first, making it an ideal shard key for MongoDB sharded clusters. Architects and DBAs should note this when designing their sharding strategy. + ## Integration Tests Set the environment variable before running integration tests: - ```bash export MONGODB_CONNECTION_STRING="mongodb://localhost:27017" dotnet test --filter 'Category=Integration' -``` +``` \ No newline at end of file diff --git a/src/RecurringThings.PostgreSQL/Data/Entities/EntityMapper.cs b/src/RecurringThings.PostgreSQL/Data/Entities/EntityMapper.cs index 642bdd7..1d6b3f8 100644 --- a/src/RecurringThings.PostgreSQL/Data/Entities/EntityMapper.cs +++ b/src/RecurringThings.PostgreSQL/Data/Entities/EntityMapper.cs @@ -2,6 +2,7 @@ namespace RecurringThings.PostgreSQL.Data.Entities; using System; using RecurringThings.Domain; +using RecurringThings.Options; /// /// Maps between domain entities and EF Core entities. @@ -28,7 +29,8 @@ public static RecurrenceEntity ToEntity(Recurrence recurrence) RecurrenceEndTime = recurrence.RecurrenceEndTime, RRule = recurrence.RRule, TimeZone = recurrence.TimeZone, - Extensions = recurrence.Extensions + Extensions = recurrence.Extensions, + MonthDayBehavior = SerializeMonthDayBehavior(recurrence.MonthDayBehavior) }; } @@ -52,7 +54,8 @@ public static Recurrence ToDomain(RecurrenceEntity entity) RecurrenceEndTime = DateTime.SpecifyKind(entity.RecurrenceEndTime, DateTimeKind.Utc), RRule = entity.RRule, TimeZone = entity.TimeZone, - Extensions = entity.Extensions + Extensions = entity.Extensions, + MonthDayBehavior = ParseMonthDayBehavior(entity.MonthDayBehavior) }; } @@ -197,4 +200,34 @@ public static OccurrenceOverride ToDomain(OccurrenceOverrideEntity entity) return @override; } + + /// + /// Parses a string value to . + /// + /// The string value ("skip" or "clamp"). + /// The parsed strategy, or null if the value is null or empty. + private static MonthDayOutOfBoundsStrategy? ParseMonthDayBehavior(string? value) + { + return value switch + { + "skip" => MonthDayOutOfBoundsStrategy.Skip, + "clamp" => MonthDayOutOfBoundsStrategy.Clamp, + _ => null + }; + } + + /// + /// Serializes a to a string value. + /// + /// The strategy to serialize. + /// The string value ("skip" or "clamp"), or null if the strategy is null. + private static string? SerializeMonthDayBehavior(MonthDayOutOfBoundsStrategy? strategy) + { + return strategy switch + { + MonthDayOutOfBoundsStrategy.Skip => "skip", + MonthDayOutOfBoundsStrategy.Clamp => "clamp", + _ => null + }; + } } diff --git a/src/RecurringThings.PostgreSQL/Data/Entities/RecurrenceEntity.cs b/src/RecurringThings.PostgreSQL/Data/Entities/RecurrenceEntity.cs index 88e5f7a..6f00c47 100644 --- a/src/RecurringThings.PostgreSQL/Data/Entities/RecurrenceEntity.cs +++ b/src/RecurringThings.PostgreSQL/Data/Entities/RecurrenceEntity.cs @@ -82,6 +82,22 @@ internal sealed class RecurrenceEntity [Column("extensions", TypeName = "jsonb")] public Dictionary? Extensions { get; set; } + /// + /// Gets or sets the strategy for out-of-bounds monthly days. + /// + /// + /// + /// Null when not applicable (non-monthly patterns, monthly patterns with day <= 28, + /// or patterns where no months are affected). + /// + /// + /// Values: "skip" or "clamp". + /// + /// + [MaxLength(10)] + [Column("month_day_behavior")] + public string? MonthDayBehavior { get; set; } + /// /// Gets or sets the navigation property for exceptions. /// diff --git a/src/RecurringThings.PostgreSQL/Migrations/20260127200000_AddMonthDayBehavior.Designer.cs b/src/RecurringThings.PostgreSQL/Migrations/20260127200000_AddMonthDayBehavior.Designer.cs new file mode 100644 index 0000000..cfa3c85 --- /dev/null +++ b/src/RecurringThings.PostgreSQL/Migrations/20260127200000_AddMonthDayBehavior.Designer.cs @@ -0,0 +1,286 @@ +// +namespace RecurringThings.PostgreSQL.Migrations; + +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RecurringThings.PostgreSQL.Data; + +#nullable disable + +[DbContext(typeof(RecurringThingsDbContext))] +[Migration("20260127200000_AddMonthDayBehavior")] +partial class AddMonthDayBehavior +{ + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("RecurringThings.PostgreSQL.Data.Entities.OccurrenceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Duration") + .HasColumnType("interval") + .HasColumnName("duration"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time"); + + b.Property("Extensions") + .HasColumnType("jsonb") + .HasColumnName("extensions"); + + b.Property("Organization") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("organization"); + + b.Property("ResourcePath") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("resource_path"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("time_zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("Organization", "ResourcePath", "Type", "StartTime", "EndTime") + .HasDatabaseName("idx_occurrences_query"); + + b.ToTable("occurrences"); + }); + + modelBuilder.Entity("RecurringThings.PostgreSQL.Data.Entities.OccurrenceExceptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Extensions") + .HasColumnType("jsonb") + .HasColumnName("extensions"); + + b.Property("Organization") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("organization"); + + b.Property("OriginalTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("original_time_utc"); + + b.Property("RecurrenceId") + .HasColumnType("uuid") + .HasColumnName("recurrence_id"); + + b.Property("ResourcePath") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("resource_path"); + + b.HasKey("Id"); + + b.HasIndex("RecurrenceId") + .HasDatabaseName("idx_exceptions_recurrence"); + + b.HasIndex("Organization", "ResourcePath", "OriginalTimeUtc") + .HasDatabaseName("idx_exceptions_query"); + + b.ToTable("occurrence_exceptions"); + }); + + modelBuilder.Entity("RecurringThings.PostgreSQL.Data.Entities.OccurrenceOverrideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Duration") + .HasColumnType("interval") + .HasColumnName("duration"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time"); + + b.Property("Extensions") + .HasColumnType("jsonb") + .HasColumnName("extensions"); + + b.Property("Organization") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("organization"); + + b.Property("OriginalDuration") + .HasColumnType("interval") + .HasColumnName("original_duration"); + + b.Property("OriginalExtensions") + .HasColumnType("jsonb") + .HasColumnName("original_extensions"); + + b.Property("OriginalTimeUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("original_time_utc"); + + b.Property("RecurrenceId") + .HasColumnType("uuid") + .HasColumnName("recurrence_id"); + + b.Property("ResourcePath") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("resource_path"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.HasKey("Id"); + + b.HasIndex("RecurrenceId") + .HasDatabaseName("idx_overrides_recurrence"); + + b.HasIndex("Organization", "ResourcePath", "OriginalTimeUtc") + .HasDatabaseName("idx_overrides_original"); + + b.HasIndex("Organization", "ResourcePath", "StartTime", "EndTime") + .HasDatabaseName("idx_overrides_start"); + + b.ToTable("occurrence_overrides"); + }); + + modelBuilder.Entity("RecurringThings.PostgreSQL.Data.Entities.RecurrenceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Duration") + .HasColumnType("interval") + .HasColumnName("duration"); + + b.Property("Extensions") + .HasColumnType("jsonb") + .HasColumnName("extensions"); + + b.Property("MonthDayBehavior") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("month_day_behavior"); + + b.Property("Organization") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("organization"); + + b.Property("RRule") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("r_rule"); + + b.Property("RecurrenceEndTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("recurrence_end_time"); + + b.Property("ResourcePath") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("resource_path"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("time_zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("Organization", "ResourcePath", "Type", "StartTime", "RecurrenceEndTime") + .HasDatabaseName("idx_recurrences_query"); + + b.ToTable("recurrences"); + }); + + modelBuilder.Entity("RecurringThings.PostgreSQL.Data.Entities.OccurrenceExceptionEntity", b => + { + b.HasOne("RecurringThings.PostgreSQL.Data.Entities.RecurrenceEntity", "Recurrence") + .WithMany("Exceptions") + .HasForeignKey("RecurrenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recurrence"); + }); + + modelBuilder.Entity("RecurringThings.PostgreSQL.Data.Entities.OccurrenceOverrideEntity", b => + { + b.HasOne("RecurringThings.PostgreSQL.Data.Entities.RecurrenceEntity", "Recurrence") + .WithMany("Overrides") + .HasForeignKey("RecurrenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recurrence"); + }); + + modelBuilder.Entity("RecurringThings.PostgreSQL.Data.Entities.RecurrenceEntity", b => + { + b.Navigation("Exceptions"); + + b.Navigation("Overrides"); + }); +#pragma warning restore 612, 618 + } +} diff --git a/src/RecurringThings.PostgreSQL/Migrations/20260127200000_AddMonthDayBehavior.cs b/src/RecurringThings.PostgreSQL/Migrations/20260127200000_AddMonthDayBehavior.cs new file mode 100644 index 0000000..9dfa563 --- /dev/null +++ b/src/RecurringThings.PostgreSQL/Migrations/20260127200000_AddMonthDayBehavior.cs @@ -0,0 +1,28 @@ +namespace RecurringThings.PostgreSQL.Migrations; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +/// +public partial class AddMonthDayBehavior : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "month_day_behavior", + table: "recurrences", + type: "character varying(10)", + maxLength: 10, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "month_day_behavior", + table: "recurrences"); + } +} diff --git a/src/RecurringThings.PostgreSQL/Migrations/RecurringThingsDbContextModelSnapshot.cs b/src/RecurringThings.PostgreSQL/Migrations/RecurringThingsDbContextModelSnapshot.cs index a32a6dd..1a9ea30 100644 --- a/src/RecurringThings.PostgreSQL/Migrations/RecurringThingsDbContextModelSnapshot.cs +++ b/src/RecurringThings.PostgreSQL/Migrations/RecurringThingsDbContextModelSnapshot.cs @@ -199,6 +199,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("jsonb") .HasColumnName("extensions"); + b.Property("MonthDayBehavior") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("month_day_behavior"); + b.Property("Organization") .IsRequired() .HasMaxLength(100) diff --git a/src/RecurringThings/Domain/Recurrence.cs b/src/RecurringThings/Domain/Recurrence.cs index e59aea0..24de6e7 100644 --- a/src/RecurringThings/Domain/Recurrence.cs +++ b/src/RecurringThings/Domain/Recurrence.cs @@ -2,6 +2,7 @@ namespace RecurringThings.Domain; using System; using System.Collections.Generic; +using RecurringThings.Options; /// /// Represents a recurring event pattern that generates virtualized occurrences on-demand. @@ -104,4 +105,20 @@ internal sealed class Recurrence /// Value constraints: 0-1024 characters, non-null. /// public Dictionary? Extensions { get; set; } + + /// + /// Gets the strategy for handling out-of-bounds days in monthly recurrences. + /// + /// + /// This property is immutable after creation. + /// + /// Only set when the RRule has FREQ=MONTHLY with BYMONTHDAY values that don't exist + /// in all months within the recurrence range (e.g., 31 doesn't exist in April). + /// + /// + /// Null for non-monthly patterns, monthly patterns with day <= 28, or when no months + /// in the range are affected. + /// + /// + public MonthDayOutOfBoundsStrategy? MonthDayBehavior { get; init; } } diff --git a/src/RecurringThings/Engine/IRecurrenceEngine.cs b/src/RecurringThings/Engine/IRecurrenceEngine.cs index be15cc4..079e0f2 100644 --- a/src/RecurringThings/Engine/IRecurrenceEngine.cs +++ b/src/RecurringThings/Engine/IRecurrenceEngine.cs @@ -4,7 +4,9 @@ namespace RecurringThings.Engine; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using RecurringThings.Exceptions; using RecurringThings.Models; +using RecurringThings.Options; using Transactional.Abstractions; /// @@ -115,12 +117,22 @@ IAsyncEnumerable GetRecurrencesAsync( /// The RFC 5545 recurrence rule. Must use UNTIL in UTC (Z suffix); COUNT is not supported. /// The IANA time zone identifier (e.g., "America/New_York"). /// Optional user-defined key-value metadata. + /// + /// Optional options for recurrence creation. Controls behavior for monthly recurrences + /// where the specified day doesn't exist in all months (e.g., 31st in April). + /// /// Optional transaction context. /// A token to cancel the operation. /// A representing the created recurrence with . /// /// Thrown when validation fails (invalid RRule, missing UNTIL, COUNT used, field length violations, etc.). /// + /// + /// Thrown when the RRule specifies a monthly recurrence with a day that doesn't exist in all months + /// within the recurrence range, and no are provided or + /// is . + /// The exception contains the affected months, allowing the caller to prompt the user for a choice. + /// /// /// /// var pattern = new RecurrencePattern @@ -149,6 +161,7 @@ Task CreateRecurrenceAsync( string rrule, string timeZone, Dictionary? extensions = null, + CreateRecurrenceOptions? options = null, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default); diff --git a/src/RecurringThings/Engine/RecurrenceEngine.cs b/src/RecurringThings/Engine/RecurrenceEngine.cs index cde07b6..0d78154 100644 --- a/src/RecurringThings/Engine/RecurrenceEngine.cs +++ b/src/RecurringThings/Engine/RecurrenceEngine.cs @@ -6,12 +6,12 @@ namespace RecurringThings.Engine; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using Ical.Net; -using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using NodaTime; using RecurringThings.Domain; +using RecurringThings.Engine.Virtualization; using RecurringThings.Models; +using RecurringThings.Options; using RecurringThings.Repository; using RecurringThings.Validation; using Transactional.Abstractions; @@ -220,6 +220,7 @@ public async Task CreateRecurrenceAsync( string rrule, string timeZone, Dictionary? extensions = null, + CreateRecurrenceOptions? options = null, ITransactionContext? transactionContext = null, CancellationToken cancellationToken = default) { @@ -229,8 +230,16 @@ public async Task CreateRecurrenceAsync( // Convert input time to UTC if it's local var startTimeUtc = ConvertToUtc(startTime, timeZone); + // Parse RRule ONCE and reuse + var pattern = new RecurrencePattern(rrule); + // Extract RecurrenceEndTime from RRule UNTIL clause - var recurrenceEndTimeUtc = ExtractUntilFromRRule(rrule); + var recurrenceEndTimeUtc = pattern.Until is not null + ? DateTime.SpecifyKind(pattern.Until.Value, DateTimeKind.Utc) + : throw new ArgumentException("RRule must contain UNTIL clause.", nameof(rrule)); + + // Validate monthly day bounds - returns strategy or null, throws if Throw strategy + var monthDayBehavior = Validator.ValidateMonthlyDayBounds(pattern, startTimeUtc, options); // Create the recurrence entity var recurrence = new Recurrence @@ -244,7 +253,8 @@ public async Task CreateRecurrenceAsync( RecurrenceEndTime = recurrenceEndTimeUtc, RRule = rrule, TimeZone = timeZone, - Extensions = extensions + Extensions = extensions, + MonthDayBehavior = monthDayBehavior }; // Persist via repository @@ -302,80 +312,25 @@ public async Task CreateOccurrenceAsync( /// /// Generates virtualized occurrence times from a recurrence pattern. /// + /// + /// + /// This method delegates to the appropriate occurrence generator based on the + /// recurrence's setting: + /// + /// + /// uses + /// All other cases (null, Skip) use + /// + /// private static IEnumerable GenerateOccurrences(Recurrence recurrence, DateTime startUtc, DateTime endUtc) { - // Get the IANA timezone - var timeZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(recurrence.TimeZone); - if (timeZone is null) - { - yield break; - } - - // Convert query range to local time - var startInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(startUtc, DateTimeKind.Utc)); - var endInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(endUtc, DateTimeKind.Utc)); + // Parse RRule ONCE and reuse + var pattern = new RecurrencePattern(recurrence.RRule); - var startLocal = startInstant.InZone(timeZone).LocalDateTime; - var endLocal = endInstant.InZone(timeZone).LocalDateTime; + // Get the appropriate generator based on MonthDayBehavior + var generator = OccurrenceGeneratorFactory.GetGenerator(recurrence); - // Convert recurrence start time to local - var recurrenceStartInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(recurrence.StartTime, DateTimeKind.Utc)); - var recurrenceStartLocal = recurrenceStartInstant.InZone(timeZone).LocalDateTime; - - // Create a calendar event with the RRule - var calendar = new Calendar(); - var calendarEvent = new CalendarEvent - { - DtStart = new CalDateTime( - recurrenceStartLocal.Year, - recurrenceStartLocal.Month, - recurrenceStartLocal.Day, - recurrenceStartLocal.Hour, - recurrenceStartLocal.Minute, - recurrenceStartLocal.Second, - recurrence.TimeZone) - }; - - // Parse and add the RRule - var rrule = new RecurrencePattern(recurrence.RRule); - calendarEvent.RecurrenceRules.Add(rrule); - - calendar.Events.Add(calendarEvent); - - // Get occurrences in the local time range - // In Ical.Net 5.x, use GetOccurrences(start) and filter with TakeWhileBefore(end) - var searchStart = new CalDateTime(startLocal.Year, startLocal.Month, startLocal.Day, 0, 0, 0, recurrence.TimeZone); - var searchEnd = new CalDateTime(endLocal.Year, endLocal.Month, endLocal.Day, 23, 59, 59, recurrence.TimeZone); - - var icalOccurrences = calendarEvent.GetOccurrences(searchStart).TakeWhileBefore(searchEnd); - - foreach (var icalOccurrence in icalOccurrences) - { - var occurrenceDateTime = icalOccurrence.Period.StartTime; - - // Convert back to UTC using NodaTime for correct DST handling - var localDateTime = new LocalDateTime( - occurrenceDateTime.Year, - occurrenceDateTime.Month, - occurrenceDateTime.Day, - occurrenceDateTime.Hour, - occurrenceDateTime.Minute, - occurrenceDateTime.Second); - - // Handle ambiguous times during DST fall-back - var zonedDateTime = localDateTime.InZoneLeniently(timeZone); - var utcDateTime = zonedDateTime.ToDateTimeUtc(); - - // Filter to exact query range - if (utcDateTime >= startUtc && utcDateTime <= endUtc) - { - // Also check against RecurrenceEndTime - if (utcDateTime <= recurrence.RecurrenceEndTime) - { - yield return utcDateTime; - } - } - } + return generator.GenerateOccurrences(recurrence, pattern, startUtc, endUtc); } /// @@ -399,7 +354,11 @@ private static CalendarEntry CreateVirtualizedEntry(Recurrence recurrence, DateT Extensions = recurrence.Extensions, RecurrenceId = recurrence.Id, EntryType = CalendarEntryType.Virtualized, - RecurrenceDetails = new RecurrenceDetails { RRule = recurrence.RRule }, + RecurrenceDetails = new RecurrenceDetails + { + RRule = recurrence.RRule, + MonthDayBehavior = recurrence.MonthDayBehavior + }, Original = new OriginalDetails { StartTime = occurrenceTimeUtc, @@ -430,7 +389,11 @@ private static CalendarEntry CreateOverriddenEntry(Recurrence recurrence, Occurr RecurrenceId = recurrence.Id, OverrideId = @override.Id, EntryType = CalendarEntryType.Virtualized, - RecurrenceDetails = new RecurrenceDetails { RRule = recurrence.RRule }, + RecurrenceDetails = new RecurrenceDetails + { + RRule = recurrence.RRule, + MonthDayBehavior = recurrence.MonthDayBehavior + }, Original = new OriginalDetails { StartTime = @override.OriginalTimeUtc, @@ -484,7 +447,11 @@ private static CalendarEntry CreateRecurrenceEntry(Recurrence recurrence) Extensions = recurrence.Extensions, RecurrenceId = recurrence.Id, EntryType = CalendarEntryType.Recurrence, - RecurrenceDetails = new RecurrenceDetails { RRule = recurrence.RRule }, + RecurrenceDetails = new RecurrenceDetails + { + RRule = recurrence.RRule, + MonthDayBehavior = recurrence.MonthDayBehavior + }, Original = null }; } @@ -986,25 +953,6 @@ public async IAsyncEnumerable GetRecurrencesAsync( } } - /// - /// 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/Engine/Virtualization/ClampedMonthlyOccurrenceGenerator.cs b/src/RecurringThings/Engine/Virtualization/ClampedMonthlyOccurrenceGenerator.cs new file mode 100644 index 0000000..c67a716 --- /dev/null +++ b/src/RecurringThings/Engine/Virtualization/ClampedMonthlyOccurrenceGenerator.cs @@ -0,0 +1,124 @@ +namespace RecurringThings.Engine.Virtualization; + +using System; +using System.Collections.Generic; +using System.Linq; +using Ical.Net.DataTypes; +using NodaTime; +using RecurringThings.Domain; + +/// +/// Generates occurrences for monthly patterns with Clamp strategy. +/// +/// +/// +/// This generator proactively iterates through all months in the recurrence range +/// and clamps out-of-bounds days to the last day of each month. +/// +/// +/// For example, a recurrence set to the 31st of each month will generate: +/// +/// +/// January 31st (31 days in month) +/// February 28th/29th (clamped from 31) +/// March 31st (31 days in month) +/// April 30th (clamped from 31) +/// +/// +/// Unlike detecting skipped months after Ical.Net evaluation, this approach correctly +/// handles edge cases like when the last month of the recurrence would be skipped. +/// +/// +/// Important: Clamped occurrences respect the UNTIL date. If UNTIL is March 15 +/// and BYMONTHDAY is 30, no March occurrence is generated because March 30 exceeds UNTIL. +/// +/// +internal sealed class ClampedMonthlyOccurrenceGenerator : IOccurrenceGenerator +{ + /// + public IEnumerable GenerateOccurrences( + Recurrence recurrence, + RecurrencePattern pattern, + DateTime queryStartUtc, + DateTime queryEndUtc) + { + // Get the IANA timezone + var timeZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(recurrence.TimeZone); + if (timeZone is null) + { + yield break; + } + + // Get the target day from BYMONTHDAY or from the recurrence start + var targetDay = pattern.ByMonthDay.Count > 0 + ? pattern.ByMonthDay.Max() + : GetLocalDay(recurrence.StartTime, timeZone); + + // Get the time-of-day from the recurrence start (in local time) + var recurrenceStartInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(recurrence.StartTime, DateTimeKind.Utc)); + var recurrenceStartLocal = recurrenceStartInstant.InZone(timeZone).LocalDateTime; + var timeOfDay = recurrenceStartLocal.TimeOfDay; + + // Convert UNTIL to local time for month iteration + var untilInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(recurrence.RecurrenceEndTime, DateTimeKind.Utc)); + var untilLocal = untilInstant.InZone(timeZone).LocalDateTime; + + // Start from the first month of the recurrence + var currentMonth = new LocalDate(recurrenceStartLocal.Year, recurrenceStartLocal.Month, 1); + var untilDate = untilLocal.Date; + + while (currentMonth <= untilDate) + { + // Calculate the actual day (clamped to the number of days in the month) + var daysInMonth = currentMonth.Calendar.GetDaysInMonth(currentMonth.Year, currentMonth.Month); + var actualDay = Math.Min(targetDay, daysInMonth); + + // Build the occurrence local date time + var occurrenceLocal = new LocalDateTime( + currentMonth.Year, + currentMonth.Month, + actualDay, + timeOfDay.Hour, + timeOfDay.Minute, + timeOfDay.Second); + + // Convert to UTC + var zonedDateTime = occurrenceLocal.InZoneLeniently(timeZone); + var utcDateTime = zonedDateTime.ToDateTimeUtc(); + + // Check: occurrence must not exceed the recurrence end time + if (utcDateTime > recurrence.RecurrenceEndTime) + { + // If clamped occurrence exceeds UNTIL, skip this month + // (e.g., UNTIL=March 15, BYMONTHDAY=30 → no March occurrence) + currentMonth = currentMonth.PlusMonths(1); + continue; + } + + // Check: occurrence must be at or after the recurrence start + if (utcDateTime < recurrence.StartTime) + { + currentMonth = currentMonth.PlusMonths(1); + continue; + } + + // Check: occurrence must be within query range + if (utcDateTime >= queryStartUtc && utcDateTime <= queryEndUtc) + { + yield return utcDateTime; + } + + currentMonth = currentMonth.PlusMonths(1); + } + } + + /// + /// Gets the day of month from a UTC DateTime in the specified timezone. + /// + private static int GetLocalDay(DateTime utcDateTime, DateTimeZone timeZone) + { + var instant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(utcDateTime, DateTimeKind.Utc)); + var local = instant.InZone(timeZone).LocalDateTime; + return local.Day; + } +} diff --git a/src/RecurringThings/Engine/Virtualization/IOccurrenceGenerator.cs b/src/RecurringThings/Engine/Virtualization/IOccurrenceGenerator.cs new file mode 100644 index 0000000..80466b8 --- /dev/null +++ b/src/RecurringThings/Engine/Virtualization/IOccurrenceGenerator.cs @@ -0,0 +1,49 @@ +namespace RecurringThings.Engine.Virtualization; + +using System; +using System.Collections.Generic; +using Ical.Net.DataTypes; +using RecurringThings.Domain; + +/// +/// Generates occurrence dates for a recurrence pattern. +/// +/// +/// +/// Implementations of this interface are responsible for generating virtualized occurrence times +/// from a recurrence pattern within a query range. +/// +/// +/// Different implementations handle different strategies: +/// +/// +/// - Standard Ical.Net evaluation (also handles Skip strategy) +/// - Clamps out-of-bounds days to month end +/// +/// +internal interface IOccurrenceGenerator +{ + /// + /// Generates occurrence dates within the specified range. + /// + /// The recurrence containing the pattern and timezone. + /// The parsed recurrence pattern (parsed once and reused). + /// The start of the query range in UTC. + /// The end of the query range in UTC. + /// An enumerable of UTC DateTime values representing occurrence times. + /// + /// + /// The returned dates must be in UTC and must fall within the query range + /// and not exceed the recurrence's end time. + /// + /// + /// The parameter is provided to avoid re-parsing + /// the RRule string multiple times. + /// + /// + IEnumerable GenerateOccurrences( + Recurrence recurrence, + RecurrencePattern pattern, + DateTime queryStartUtc, + DateTime queryEndUtc); +} diff --git a/src/RecurringThings/Engine/Virtualization/IcalNetOccurrenceGenerator.cs b/src/RecurringThings/Engine/Virtualization/IcalNetOccurrenceGenerator.cs new file mode 100644 index 0000000..4f32e67 --- /dev/null +++ b/src/RecurringThings/Engine/Virtualization/IcalNetOccurrenceGenerator.cs @@ -0,0 +1,109 @@ +namespace RecurringThings.Engine.Virtualization; + +using System; +using System.Collections.Generic; +using Ical.Net; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using NodaTime; +using RecurringThings.Domain; + +/// +/// Generates occurrences using Ical.Net's RRule evaluation. +/// +/// +/// +/// This generator uses the standard Ical.Net library to evaluate recurrence patterns. +/// It is used for: +/// +/// +/// Non-monthly patterns (daily, weekly, yearly) +/// Monthly patterns without out-of-bounds issues +/// Monthly patterns with strategy +/// +/// +/// Ical.Net naturally skips dates that don't exist (e.g., February 30th), which implements +/// the Skip behavior for out-of-bounds monthly days. +/// +/// +internal sealed class IcalNetOccurrenceGenerator : IOccurrenceGenerator +{ + /// + public IEnumerable GenerateOccurrences( + Recurrence recurrence, + RecurrencePattern pattern, + DateTime queryStartUtc, + DateTime queryEndUtc) + { + // Get the IANA timezone + var timeZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(recurrence.TimeZone); + if (timeZone is null) + { + yield break; + } + + // Convert query range to local time + var startInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(queryStartUtc, DateTimeKind.Utc)); + var endInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(queryEndUtc, DateTimeKind.Utc)); + + var startLocal = startInstant.InZone(timeZone).LocalDateTime; + var endLocal = endInstant.InZone(timeZone).LocalDateTime; + + // Convert recurrence start time to local + var recurrenceStartInstant = Instant.FromDateTimeUtc(DateTime.SpecifyKind(recurrence.StartTime, DateTimeKind.Utc)); + var recurrenceStartLocal = recurrenceStartInstant.InZone(timeZone).LocalDateTime; + + // Create a calendar event with the RRule + var calendar = new Calendar(); + var calendarEvent = new CalendarEvent + { + DtStart = new CalDateTime( + recurrenceStartLocal.Year, + recurrenceStartLocal.Month, + recurrenceStartLocal.Day, + recurrenceStartLocal.Hour, + recurrenceStartLocal.Minute, + recurrenceStartLocal.Second, + recurrence.TimeZone) + }; + + // Add the pre-parsed pattern + calendarEvent.RecurrenceRules.Add(pattern); + + calendar.Events.Add(calendarEvent); + + // Get occurrences in the local time range + var searchStart = new CalDateTime(startLocal.Year, startLocal.Month, startLocal.Day, 0, 0, 0, recurrence.TimeZone); + var searchEnd = new CalDateTime(endLocal.Year, endLocal.Month, endLocal.Day, 23, 59, 59, recurrence.TimeZone); + + var icalOccurrences = calendarEvent.GetOccurrences(searchStart).TakeWhileBefore(searchEnd); + + foreach (var icalOccurrence in icalOccurrences) + { + var occurrenceDateTime = icalOccurrence.Period.StartTime; + + // Convert back to UTC using NodaTime for correct DST handling + var localDateTime = new LocalDateTime( + occurrenceDateTime.Year, + occurrenceDateTime.Month, + occurrenceDateTime.Day, + occurrenceDateTime.Hour, + occurrenceDateTime.Minute, + occurrenceDateTime.Second); + + // Handle ambiguous times during DST fall-back + var zonedDateTime = localDateTime.InZoneLeniently(timeZone); + var utcDateTime = zonedDateTime.ToDateTimeUtc(); + + // Filter to exact query range + if (utcDateTime >= queryStartUtc && utcDateTime <= queryEndUtc) + { + // Also check against RecurrenceEndTime + if (utcDateTime <= recurrence.RecurrenceEndTime) + { + yield return utcDateTime; + } + } + } + } +} diff --git a/src/RecurringThings/Engine/Virtualization/OccurrenceGeneratorFactory.cs b/src/RecurringThings/Engine/Virtualization/OccurrenceGeneratorFactory.cs new file mode 100644 index 0000000..260ffb1 --- /dev/null +++ b/src/RecurringThings/Engine/Virtualization/OccurrenceGeneratorFactory.cs @@ -0,0 +1,57 @@ +namespace RecurringThings.Engine.Virtualization; + +using RecurringThings.Domain; +using RecurringThings.Options; + +/// +/// Creates the appropriate occurrence generator based on recurrence configuration. +/// +/// +/// +/// This factory uses a singleton pattern for generators since they are stateless. +/// The appropriate generator is selected based on the recurrence's +/// property. +/// +/// +/// Generator selection: +/// +/// +/// +/// All other cases → +/// +/// +/// The default handles: +/// +/// +/// Non-monthly patterns (MonthDayBehavior is null) +/// Monthly patterns without out-of-bounds issues (MonthDayBehavior is null) +/// strategy (Ical.Net naturally skips invalid dates) +/// +/// +internal static class OccurrenceGeneratorFactory +{ + private static readonly IcalNetOccurrenceGenerator IcalNetGenerator = new(); + private static readonly ClampedMonthlyOccurrenceGenerator ClampedGenerator = new(); + + /// + /// Gets the appropriate occurrence generator for the specified recurrence. + /// + /// The recurrence to generate occurrences for. + /// An occurrence generator appropriate for the recurrence's configuration. + /// + /// + /// Uses only when + /// is explicitly set to + /// . + /// + /// + /// For all other cases (null, Skip), uses . + /// + /// + public static IOccurrenceGenerator GetGenerator(Recurrence recurrence) + { + return recurrence.MonthDayBehavior == MonthDayOutOfBoundsStrategy.Clamp + ? ClampedGenerator + : IcalNetGenerator; + } +} diff --git a/src/RecurringThings/Exceptions/MonthDayOutOfBoundsException.cs b/src/RecurringThings/Exceptions/MonthDayOutOfBoundsException.cs new file mode 100644 index 0000000..e794b5a --- /dev/null +++ b/src/RecurringThings/Exceptions/MonthDayOutOfBoundsException.cs @@ -0,0 +1,73 @@ +namespace RecurringThings.Exceptions; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +/// +/// Exception thrown when a monthly recurrence specifies a day that doesn't exist +/// in at least one month within the recurrence range. +/// +/// +/// +/// This exception is thrown when creating a monthly recurrence with the default +/// strategy, and the specified +/// BYMONTHDAY value doesn't exist in all months within the recurrence range. +/// +/// +/// The caller should handle this exception by prompting the user to choose between +/// (skip months where the day doesn't exist) +/// or (use the last day of the month). +/// +/// +public class MonthDayOutOfBoundsException : Exception +{ + /// + /// Gets the day of month that caused the out-of-bounds condition. + /// + /// + /// This is the BYMONTHDAY value from the RRule, or the day from StartTime if BYMONTHDAY wasn't specified. + /// + public int DayOfMonth { get; } + + /// + /// Gets the months (1-12) where the specified day doesn't exist. + /// + /// + /// + /// Month numbers follow the standard convention: 1 = January, 2 = February, etc. + /// + /// + /// For example, if is 31, this list would contain + /// 2 (February), 4 (April), 6 (June), 9 (September), and 11 (November). + /// + /// + public IReadOnlyList AffectedMonths { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The day of month that doesn't exist in all months. + /// The list of month numbers (1-12) where the day doesn't exist. + public MonthDayOutOfBoundsException(int dayOfMonth, IReadOnlyList affectedMonths) + : base(FormatMessage(dayOfMonth, affectedMonths)) + { + DayOfMonth = dayOfMonth; + AffectedMonths = affectedMonths; + } + + private static string FormatMessage(int dayOfMonth, IReadOnlyList affectedMonths) + { + var monthNames = FormatMonthNames(affectedMonths); + return $"Monthly recurrence day {dayOfMonth} doesn't exist in all months. " + + $"Affected months: {monthNames}. " + + "Consider using Skip or Clamp strategy."; + } + + private static string FormatMonthNames(IReadOnlyList months) + { + return string.Join(", ", months.Select(m => + CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(m))); + } +} diff --git a/src/RecurringThings/Models/RecurrenceDetails.cs b/src/RecurringThings/Models/RecurrenceDetails.cs index 5bca164..75534fc 100644 --- a/src/RecurringThings/Models/RecurrenceDetails.cs +++ b/src/RecurringThings/Models/RecurrenceDetails.cs @@ -1,5 +1,7 @@ namespace RecurringThings.Models; +using RecurringThings.Options; + /// /// Contains recurrence-specific details when a represents a recurrence pattern. /// @@ -17,4 +19,24 @@ public sealed class RecurrenceDetails /// The recurrence end time can be extracted by parsing the UNTIL clause from this RRule. /// public required string RRule { get; set; } + + /// + /// Gets or sets the strategy used for handling out-of-bounds days in monthly recurrences. + /// + /// + /// + /// Null when the recurrence doesn't have out-of-bounds day handling configured, + /// which includes: + /// + /// + /// Non-monthly patterns (daily, weekly, yearly) + /// Monthly patterns with BYMONTHDAY values <= 28 + /// Monthly patterns where all months in the range can accommodate the specified day + /// + /// + /// When set, this indicates how virtualization handles months where the specified day + /// doesn't exist (e.g., 31st in April). + /// + /// + public MonthDayOutOfBoundsStrategy? MonthDayBehavior { get; init; } } diff --git a/src/RecurringThings/Options/CreateRecurrenceOptions.cs b/src/RecurringThings/Options/CreateRecurrenceOptions.cs new file mode 100644 index 0000000..122316f --- /dev/null +++ b/src/RecurringThings/Options/CreateRecurrenceOptions.cs @@ -0,0 +1,27 @@ +namespace RecurringThings.Options; + +/// +/// Options for creating a recurrence pattern. +/// +/// +/// Pass this to CreateRecurrenceAsync to customize recurrence creation behavior. +/// +public class CreateRecurrenceOptions +{ + /// + /// Gets or sets the strategy for handling monthly recurrences where the specified day + /// doesn't exist in all months within the recurrence range. + /// + /// + /// + /// Default is , which throws an exception + /// to allow the caller to prompt the user for a choice. + /// + /// + /// This option only applies to monthly recurrences (FREQ=MONTHLY) with BYMONTHDAY values + /// greater than 28. For other frequencies or day values, this option has no effect. + /// + /// + public MonthDayOutOfBoundsStrategy OutOfBoundsMonthBehavior { get; set; } + = MonthDayOutOfBoundsStrategy.Throw; +} diff --git a/src/RecurringThings/Options/MonthDayOutOfBoundsStrategy.cs b/src/RecurringThings/Options/MonthDayOutOfBoundsStrategy.cs new file mode 100644 index 0000000..b2e07e1 --- /dev/null +++ b/src/RecurringThings/Options/MonthDayOutOfBoundsStrategy.cs @@ -0,0 +1,62 @@ +namespace RecurringThings.Options; + +/// +/// Specifies how to handle monthly recurrences where the specified day +/// doesn't exist in all months within the recurrence range. +/// +/// +/// +/// When creating a monthly recurrence with a day that doesn't exist in all months +/// (e.g., 31st doesn't exist in April, June, September, November, or February), +/// this strategy determines the behavior. +/// +/// +/// The default strategy is , which allows the caller to +/// prompt the user for a choice before proceeding. +/// +/// +public enum MonthDayOutOfBoundsStrategy +{ + /// + /// Throws a when the day falls + /// out of bounds for at least one month within the recurrence range. + /// + /// + /// This is the default behavior, allowing the caller to prompt the user for a choice + /// before calling CreateRecurrenceAsync again with or . + /// + Throw = 0, + + /// + /// Skips months where the specified day doesn't exist. + /// + /// + /// + /// For example, a recurrence on the 31st will skip April, June, September, November, + /// and February (which have fewer than 31 days). + /// + /// + /// This results in fewer occurrences than months in the range. + /// + /// + Skip = 1, + + /// + /// Automatically selects the last day of the month when the specified day + /// exceeds the number of days in that month. + /// + /// + /// + /// For example, if the recurrence is set to the 31st: + /// + /// April (30 days) → 30th + /// February (28/29 days) → 28th or 29th + /// June (30 days) → 30th + /// + /// + /// + /// This ensures every month in the range has exactly one occurrence. + /// + /// + Clamp = 2 +} diff --git a/src/RecurringThings/Validation/Validator.cs b/src/RecurringThings/Validation/Validator.cs index 358d8f2..d0350fa 100644 --- a/src/RecurringThings/Validation/Validator.cs +++ b/src/RecurringThings/Validation/Validator.cs @@ -2,10 +2,13 @@ namespace RecurringThings.Validation; using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using Ical.Net.DataTypes; using NodaTime; using RecurringThings.Domain; +using RecurringThings.Exceptions; +using RecurringThings.Options; /// /// Provides validation logic for RecurringThings entities and request models. @@ -312,4 +315,124 @@ private static void ValidateExtensions(Dictionary? extensions) [GeneratedRegex(@"(?:^|;)UNTIL\s*=\s*(?[^;]+)", RegexOptions.IgnoreCase)] private static partial Regex UntilRegex(); + + /// + /// Validates monthly recurrence day bounds and returns affected months if any. + /// + /// The parsed recurrence pattern. + /// The recurrence start time. + /// List of affected month numbers (1-12), or empty if no issues. + /// + /// + /// Returns an empty list when: + /// + /// + /// Pattern is not MONTHLY frequency + /// All BYMONTHDAY values are <= 28 (safe for all months) + /// No months in the recurrence range are affected + /// + /// + internal static IReadOnlyList GetAffectedMonthsForMonthlyPattern( + RecurrencePattern pattern, + DateTime startTime) + { + // Only validate for MONTHLY frequency + if (pattern.Frequency != Ical.Net.FrequencyType.Monthly) + { + return []; + } + + // Get BYMONTHDAY values (if empty, defaults to start day) + var byMonthDays = pattern.ByMonthDay.Count > 0 + ? pattern.ByMonthDay.ToList() + : [startTime.Day]; + + // If all days are <= 28, no month will have issues + if (byMonthDays.All(d => d <= 28)) + { + return []; + } + + // Get the recurrence end date from UNTIL (IDateTime -> DateTime) + var until = pattern.Until; + if (until is null) + { + return []; + } + + var untilDate = until.Value; + + // Find affected months in the range + var affectedMonths = new HashSet(); + var current = new DateTime(startTime.Year, startTime.Month, 1); + + while (current <= untilDate) + { + var daysInMonth = DateTime.DaysInMonth(current.Year, current.Month); + + foreach (var day in byMonthDays) + { + if (day > daysInMonth) + { + affectedMonths.Add(current.Month); + } + } + + current = current.AddMonths(1); + } + + return affectedMonths.Order().ToList(); + } + + /// + /// Validates monthly day bounds and throws or returns the effective strategy. + /// + /// The parsed recurrence pattern. + /// The recurrence start time. + /// The creation options (optional). + /// The strategy to use, or null if no out-of-bounds handling needed. + /// + /// Thrown when strategy is and affected months exist. + /// + /// + /// + /// Returns null when: + /// + /// + /// Pattern is not MONTHLY (daily, weekly, yearly patterns don't need this) + /// All BYMONTHDAY values are <= 28 (safe for all months) + /// No affected months exist in the recurrence range + /// + /// + /// This ensures we only store the strategy when it's actually needed, + /// avoiding garbage data on non-monthly recurrences. + /// + /// + internal static MonthDayOutOfBoundsStrategy? ValidateMonthlyDayBounds( + RecurrencePattern pattern, + DateTime startTime, + CreateRecurrenceOptions? options) + { + var affectedMonths = GetAffectedMonthsForMonthlyPattern(pattern, startTime); + + // Return null for non-monthly patterns or when no months are affected + // This ensures we don't store unnecessary data + if (affectedMonths.Count == 0) + { + return null; + } + + var strategy = options?.OutOfBoundsMonthBehavior ?? MonthDayOutOfBoundsStrategy.Throw; + + if (strategy == MonthDayOutOfBoundsStrategy.Throw) + { + var byMonthDays = pattern.ByMonthDay.Count > 0 + ? pattern.ByMonthDay.ToList() + : [startTime.Day]; + + throw new MonthDayOutOfBoundsException(byMonthDays.Max(), affectedMonths); + } + + return strategy; + } } diff --git a/tests/RecurringThings.Tests/Engine/RecurrenceEngineMonthlyBoundsTests.cs b/tests/RecurringThings.Tests/Engine/RecurrenceEngineMonthlyBoundsTests.cs new file mode 100644 index 0000000..5da00de --- /dev/null +++ b/tests/RecurringThings.Tests/Engine/RecurrenceEngineMonthlyBoundsTests.cs @@ -0,0 +1,839 @@ +namespace RecurringThings.Tests.Engine; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using RecurringThings.Domain; +using RecurringThings.Engine; +using RecurringThings.Exceptions; +using RecurringThings.Models; +using RecurringThings.Options; +using RecurringThings.Repository; +using Transactional.Abstractions; +using Xunit; + +/// +/// Tests for monthly recurrence out-of-bounds day handling. +/// +public class RecurrenceEngineMonthlyBoundsTests +{ + private readonly Mock _recurrenceRepo; + private readonly Mock _occurrenceRepo; + private readonly Mock _exceptionRepo; + private readonly Mock _overrideRepo; + private readonly RecurrenceEngine _engine; + + private const string TestOrganization = "test-org"; + private const string TestResourcePath = "test/path"; + private const string TestType = "appointment"; + private const string TestTimeZone = "Etc/UTC"; + + public RecurrenceEngineMonthlyBoundsTests() + { + _recurrenceRepo = new Mock(); + _occurrenceRepo = new Mock(); + _exceptionRepo = new Mock(); + _overrideRepo = new Mock(); + + SetupEmptyRepositories(); + SetupCreateRecurrence(); + + _engine = new RecurrenceEngine( + _recurrenceRepo.Object, + _occurrenceRepo.Object, + _exceptionRepo.Object, + _overrideRepo.Object); + } + + #region Throw Strategy Tests + + [Fact] + public async Task CreateRecurrenceAsync_Monthly31st_DefaultOptions_ThrowsException() + { + // Arrange + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20241231T235959Z"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone)); + + Assert.Equal(31, exception.DayOfMonth); + Assert.Contains(2, exception.AffectedMonths); // February + Assert.Contains(4, exception.AffectedMonths); // April + Assert.Contains(6, exception.AffectedMonths); // June + Assert.Contains(9, exception.AffectedMonths); // September + Assert.Contains(11, exception.AffectedMonths); // November + } + + [Fact] + public async Task CreateRecurrenceAsync_Monthly31st_ThrowStrategy_ThrowsWithAffectedMonths() + { + // Arrange + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20240630T235959Z"; + var options = new CreateRecurrenceOptions { OutOfBoundsMonthBehavior = MonthDayOutOfBoundsStrategy.Throw }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone, + options: options)); + + Assert.Equal(31, exception.DayOfMonth); + Assert.Contains(2, exception.AffectedMonths); // February + Assert.Contains(4, exception.AffectedMonths); // April + Assert.Contains(6, exception.AffectedMonths); // June + } + + [Fact] + public async Task CreateRecurrenceAsync_Monthly30th_ThrowsForFebruary() + { + // Arrange + var startTime = new DateTime(2024, 1, 30, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=30;UNTIL=20240430T235959Z"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone)); + + Assert.Equal(30, exception.DayOfMonth); + Assert.Contains(2, exception.AffectedMonths); // February doesn't have 30 days + } + + [Fact] + public async Task CreateRecurrenceAsync_Monthly29th_ThrowsForNonLeapYearFebruary() + { + // Arrange - 2025 is not a leap year + var startTime = new DateTime(2025, 1, 29, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=29;UNTIL=20250430T235959Z"; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone)); + + Assert.Equal(29, exception.DayOfMonth); + Assert.Contains(2, exception.AffectedMonths); + } + + [Fact] + public async Task CreateRecurrenceAsync_Monthly28th_DoesNotThrow() + { + // Arrange + var startTime = new DateTime(2024, 1, 28, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=28;UNTIL=20241231T235959Z"; + + // Act + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone); + + // Assert + Assert.NotNull(result); + Assert.Null(result.RecurrenceDetails?.MonthDayBehavior); + } + + [Fact] + public async Task CreateRecurrenceAsync_DailyPattern_DoesNotValidateMonthlyBounds() + { + // Arrange - Daily pattern starting on 31st should not throw + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=DAILY;UNTIL=20241231T235959Z"; + + // Act + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone); + + // Assert + Assert.NotNull(result); + Assert.Null(result.RecurrenceDetails?.MonthDayBehavior); + } + + [Fact] + public async Task CreateRecurrenceAsync_WeeklyPattern_DoesNotValidateMonthlyBounds() + { + // Arrange + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20241231T235959Z"; + + // Act + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone); + + // Assert + Assert.NotNull(result); + Assert.Null(result.RecurrenceDetails?.MonthDayBehavior); + } + + #endregion + + #region Skip Strategy Tests + + [Fact] + public async Task CreateRecurrenceAsync_Monthly31st_SkipStrategy_CreatesRecurrence() + { + // Arrange + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20241231T235959Z"; + var options = new CreateRecurrenceOptions { OutOfBoundsMonthBehavior = MonthDayOutOfBoundsStrategy.Skip }; + + // Act + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone, + options: options); + + // Assert + Assert.NotNull(result); + Assert.Equal(CalendarEntryType.Recurrence, result.EntryType); + } + + [Fact] + public async Task CreateRecurrenceAsync_SkipStrategy_StoresStrategyInRecurrence() + { + // Arrange + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20241231T235959Z"; + var options = new CreateRecurrenceOptions { OutOfBoundsMonthBehavior = MonthDayOutOfBoundsStrategy.Skip }; + + // Act + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone, + options: options); + + // Assert + Assert.Equal(MonthDayOutOfBoundsStrategy.Skip, result.RecurrenceDetails?.MonthDayBehavior); + } + + [Fact] + public async Task GetOccurrencesAsync_Monthly31st_SkipStrategy_SkipsShortMonths() + { + // Arrange + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20240731T235959Z", + new DateTime(2024, 7, 31, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Skip); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 7, 31, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert + // Jan, Mar, May, Jul have 31 days = 4 occurrences + // Feb, Apr, Jun are skipped + Assert.Equal(4, results.Count); + Assert.All(results, r => Assert.Equal(31, r.StartTime.Day)); + } + + #endregion + + #region Clamp Strategy Tests + + [Fact] + public async Task CreateRecurrenceAsync_Monthly31st_ClampStrategy_CreatesRecurrence() + { + // Arrange + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20241231T235959Z"; + var options = new CreateRecurrenceOptions { OutOfBoundsMonthBehavior = MonthDayOutOfBoundsStrategy.Clamp }; + + // Act + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone, + options: options); + + // Assert + Assert.NotNull(result); + Assert.Equal(CalendarEntryType.Recurrence, result.EntryType); + } + + [Fact] + public async Task CreateRecurrenceAsync_ClampStrategy_StoresStrategyInRecurrence() + { + // Arrange + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var rrule = "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20241231T235959Z"; + var options = new CreateRecurrenceOptions { OutOfBoundsMonthBehavior = MonthDayOutOfBoundsStrategy.Clamp }; + + // Act + var result = await _engine.CreateRecurrenceAsync( + TestOrganization, + TestResourcePath, + TestType, + startTime, + TimeSpan.FromHours(1), + rrule, + TestTimeZone, + options: options); + + // Assert + Assert.Equal(MonthDayOutOfBoundsStrategy.Clamp, result.RecurrenceDetails?.MonthDayBehavior); + } + + [Fact] + public async Task GetOccurrencesAsync_Monthly31st_ClampStrategy_UsesLastDayOfMonth() + { + // Arrange + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20240630T235959Z", + new DateTime(2024, 6, 30, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 6, 30, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert + // All 6 months should have one occurrence + Assert.Equal(6, results.Count); + + // Check specific clamped days + var febEntry = results.FirstOrDefault(r => r.StartTime.Month == 2); + Assert.NotNull(febEntry); + Assert.Equal(29, febEntry.StartTime.Day); // 2024 is a leap year + + var aprEntry = results.FirstOrDefault(r => r.StartTime.Month == 4); + Assert.NotNull(aprEntry); + Assert.Equal(30, aprEntry.StartTime.Day); // April has 30 days + + var junEntry = results.FirstOrDefault(r => r.StartTime.Month == 6); + Assert.NotNull(junEntry); + Assert.Equal(30, junEntry.StartTime.Day); // June has 30 days + } + + [Fact] + public async Task GetOccurrencesAsync_Monthly31st_ClampStrategy_ReturnsCorrectCount() + { + // Arrange + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20241231T235959Z", + new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert - Every month should have exactly one occurrence + Assert.Equal(12, results.Count); + + // Verify all months are represented + var months = results.Select(r => r.StartTime.Month).OrderBy(m => m).ToList(); + Assert.Equal(Enumerable.Range(1, 12).ToList(), months); + } + + [Fact] + public async Task GetOccurrencesAsync_Monthly31st_ClampStrategy_NoDuplicates() + { + // Arrange + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20241231T235959Z", + new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert - No duplicate dates + var uniqueDates = results.Select(r => r.StartTime.Date).Distinct().Count(); + Assert.Equal(results.Count, uniqueDates); + } + + [Fact] + public async Task GetOccurrencesAsync_ClampStrategy_February_LeapYear_Returns29th() + { + // Arrange - 2024 is a leap year + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20240331T235959Z", + new DateTime(2024, 3, 31, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 2, 29, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert + Assert.Single(results); + Assert.Equal(29, results[0].StartTime.Day); + } + + [Fact] + public async Task GetOccurrencesAsync_ClampStrategy_February_NonLeapYear_Returns28th() + { + // Arrange - 2025 is not a leap year + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2025, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20250331T235959Z", + new DateTime(2025, 3, 31, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2025, 2, 28, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert + Assert.Single(results); + Assert.Equal(28, results[0].StartTime.Day); + } + + [Fact] + public async Task GetOccurrencesAsync_ClampStrategy_UntilBeforeClampedDay_NoOccurrence() + { + // Arrange - UNTIL is March 15, but BYMONTHDAY=30 would clamp to March 30 + // No March occurrence should be generated + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 30, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=30;UNTIL=20240315T235959Z", + new DateTime(2024, 3, 15, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 3, 31, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert - Only Jan 30 and Feb 29 (clamped from 30) + // March 30 is after UNTIL=March 15, so no March occurrence + Assert.Equal(2, results.Count); + Assert.DoesNotContain(results, r => r.StartTime.Month == 3); + } + + [Fact] + public async Task GetOccurrencesAsync_ClampStrategy_RespectsRecurrenceEndTime() + { + // Arrange + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + // RecurrenceEndTime is Feb 15, but pattern goes to June + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20240630T235959Z", + new DateTime(2024, 2, 15, 23, 59, 59, DateTimeKind.Utc), // RecurrenceEndTime overrides + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + SetupRecurrences([recurrence]); + + var queryStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 6, 30, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert - Only Jan 31 because Feb 29 (clamped) > RecurrenceEndTime (Feb 15) + Assert.Single(results); + Assert.Equal(1, results[0].StartTime.Month); + } + + #endregion + + #region Override and Exception on Clamped Occurrences Tests + + [Fact] + public async Task GetOccurrencesAsync_ClampedOccurrence_WithException_ExcludesOccurrence() + { + // Arrange + var recurrenceId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20240430T235959Z", + new DateTime(2024, 4, 30, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + // Exception on the clamped April 30th occurrence (original would be April 31st but clamped to 30th) + var exception = new OccurrenceException + { + Id = Guid.NewGuid(), + Organization = TestOrganization, + ResourcePath = TestResourcePath, + RecurrenceId = recurrenceId, + OriginalTimeUtc = new DateTime(2024, 4, 30, 9, 0, 0, DateTimeKind.Utc) + }; + + SetupRecurrences([recurrence]); + SetupExceptions([exception]); + + var queryStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 4, 30, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert - Jan 31, Feb 29, Mar 31 = 3 occurrences (April 30 excepted) + Assert.Equal(3, results.Count); + Assert.DoesNotContain(results, r => r.StartTime.Month == 4); + } + + [Fact] + public async Task GetOccurrencesAsync_ClampedOccurrence_WithOverride_ReturnsOverriddenValues() + { + // Arrange + var recurrenceId = Guid.NewGuid(); + var overrideId = Guid.NewGuid(); + var startTime = new DateTime(2024, 1, 31, 9, 0, 0, DateTimeKind.Utc); + var duration = TimeSpan.FromHours(1); + + var recurrence = CreateRecurrence( + recurrenceId, + startTime, + duration, + "FREQ=MONTHLY;BYMONTHDAY=31;UNTIL=20240430T235959Z", + new DateTime(2024, 4, 30, 23, 59, 59, DateTimeKind.Utc), + monthDayBehavior: MonthDayOutOfBoundsStrategy.Clamp); + + // Override on the clamped April 30th occurrence + var overrideEntity = new OccurrenceOverride + { + Id = overrideId, + Organization = TestOrganization, + ResourcePath = TestResourcePath, + RecurrenceId = recurrenceId, + OriginalTimeUtc = new DateTime(2024, 4, 30, 9, 0, 0, DateTimeKind.Utc), + OriginalDuration = duration, + Extensions = new Dictionary { ["modified"] = "true" } + }; + overrideEntity.Initialize( + new DateTime(2024, 4, 30, 14, 0, 0, DateTimeKind.Utc), // Changed to 2 PM + TimeSpan.FromHours(2)); // Extended to 2 hours + + SetupRecurrences([recurrence]); + SetupOverrides([overrideEntity]); + + var queryStart = new DateTime(2024, 4, 1, 0, 0, 0, DateTimeKind.Utc); + var queryEnd = new DateTime(2024, 4, 30, 23, 59, 59, DateTimeKind.Utc); + + // Act + var results = await GetResultsAsync(queryStart, queryEnd); + + // Assert + Assert.Single(results); + var entry = results[0]; + Assert.Equal(overrideId, entry.OverrideId); + Assert.Equal(14, entry.StartTime.Hour); + Assert.Equal(TimeSpan.FromHours(2), entry.Duration); + Assert.True(entry.IsOverridden); + } + + #endregion + + #region Exception Class Tests + + [Fact] + public void MonthDayOutOfBoundsException_ContainsCorrectDayAndMonths() + { + // Arrange & Act + var affectedMonths = new List { 2, 4, 6, 9, 11 }; + var exception = new MonthDayOutOfBoundsException(31, affectedMonths); + + // Assert + Assert.Equal(31, exception.DayOfMonth); + Assert.Equal(affectedMonths, exception.AffectedMonths); + } + + [Fact] + public void MonthDayOutOfBoundsException_FormatsMonthNamesCorrectly() + { + // Arrange + var affectedMonths = new List { 2, 4 }; + + // Act + var exception = new MonthDayOutOfBoundsException(30, affectedMonths); + + // Assert - Message should contain readable month names + Assert.Contains("February", exception.Message); + Assert.Contains("April", exception.Message); + } + + #endregion + + #region Helper Methods + + private void SetupEmptyRepositories() + { + _recurrenceRepo + .Setup(r => r.GetInRangeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + + _occurrenceRepo + .Setup(r => r.GetInRangeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + + _exceptionRepo + .Setup(r => r.GetByRecurrenceIdsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + + _overrideRepo + .Setup(r => r.GetInRangeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + } + + private void SetupCreateRecurrence() + { + _recurrenceRepo + .Setup(r => r.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((Recurrence r, ITransactionContext? _, CancellationToken _) => r); + } + + private void SetupRecurrences(IEnumerable recurrences) + { + _recurrenceRepo + .Setup(r => r.GetInRangeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(recurrences.ToAsyncEnumerable()); + } + + private void SetupExceptions(IEnumerable exceptions) + { + _exceptionRepo + .Setup(r => r.GetByRecurrenceIdsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(exceptions.ToAsyncEnumerable()); + } + + private void SetupOverrides(IEnumerable overrides) + { + _overrideRepo + .Setup(r => r.GetInRangeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(overrides.ToAsyncEnumerable()); + } + + private static Recurrence CreateRecurrence( + Guid id, + DateTime startTime, + TimeSpan duration, + string rrule, + DateTime recurrenceEndTime, + string type = TestType, + Dictionary? extensions = null, + MonthDayOutOfBoundsStrategy? monthDayBehavior = null) + { + return new Recurrence + { + Id = id, + Organization = TestOrganization, + ResourcePath = TestResourcePath, + Type = type, + StartTime = startTime, + Duration = duration, + RecurrenceEndTime = recurrenceEndTime, + RRule = rrule, + TimeZone = TestTimeZone, + Extensions = extensions, + MonthDayBehavior = monthDayBehavior + }; + } + + private async Task> GetResultsAsync( + DateTime start, + DateTime end, + string[]? types = null) + { + var results = new List(); + await foreach (var entry in _engine.GetOccurrencesAsync( + TestOrganization, + TestResourcePath, + start, + end, + types)) + { + results.Add(entry); + } + + return results; + } + + #endregion +}