diff --git a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs index f0e88dd..ce9bb4f 100644 --- a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs +++ b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs @@ -203,7 +203,7 @@ public static class DocumentTypes /// /// Provides mapping methods between domain entities and MongoDB documents. /// -public static class DocumentMapper +internal static class DocumentMapper { /// /// Converts a to a . diff --git a/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj b/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj index 7e4b676..05498a6 100644 --- a/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj +++ b/src/RecurringThings.MongoDB/RecurringThings.MongoDB.csproj @@ -8,9 +8,13 @@ true + + + + RecurringThings.MongoDB - RecurringThings Contributors + Miguel Tremblay MongoDB persistence provider for RecurringThings library. https://github.com/ChuckNovice/RecurringThings Apache-2.0 diff --git a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs index ab42d50..a4bc3c1 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs @@ -16,7 +16,7 @@ namespace RecurringThings.MongoDB.Repositories; /// /// MongoDB implementation of . /// -public sealed class MongoOccurrenceExceptionRepository : IOccurrenceExceptionRepository +internal sealed class MongoOccurrenceExceptionRepository : IOccurrenceExceptionRepository { private readonly IMongoCollection _collection; diff --git a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs index 85e7ef9..6b479be 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs @@ -16,7 +16,7 @@ namespace RecurringThings.MongoDB.Repositories; /// /// MongoDB implementation of . /// -public sealed class MongoOccurrenceOverrideRepository : IOccurrenceOverrideRepository +internal sealed class MongoOccurrenceOverrideRepository : IOccurrenceOverrideRepository { private readonly IMongoCollection _collection; diff --git a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs index 034eb7b..061937d 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs @@ -15,7 +15,7 @@ namespace RecurringThings.MongoDB.Repositories; /// /// MongoDB implementation of . /// -public sealed class MongoOccurrenceRepository : IOccurrenceRepository +internal sealed class MongoOccurrenceRepository : IOccurrenceRepository { private readonly IMongoCollection _collection; diff --git a/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs index 25fb3d7..578daea 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs @@ -15,7 +15,7 @@ namespace RecurringThings.MongoDB.Repositories; /// /// MongoDB implementation of . /// -public sealed class MongoRecurrenceRepository : IRecurrenceRepository +internal sealed class MongoRecurrenceRepository : IRecurrenceRepository { private readonly IMongoCollection _collection; diff --git a/src/RecurringThings/Domain/Occurrence.cs b/src/RecurringThings/Domain/Occurrence.cs index 3d735f9..dde21d4 100644 --- a/src/RecurringThings/Domain/Occurrence.cs +++ b/src/RecurringThings/Domain/Occurrence.cs @@ -12,11 +12,12 @@ namespace RecurringThings.Domain; /// are stored directly in the database. /// /// -/// After creation, , , and can be modified. +/// After creation, , , , +/// , and can be modified. /// When StartTime or Duration changes, is automatically recomputed. /// /// -public sealed class Occurrence +internal sealed class Occurrence { private DateTime _startTime; private TimeSpan _duration; @@ -35,22 +36,22 @@ public sealed class Occurrence public required string Organization { get; init; } /// - /// Gets the hierarchical resource scope. + /// Gets or sets the hierarchical resource scope. /// /// /// Used for organizing resources hierarchically (e.g., "user123/calendar", "store456"). /// Must be between 0 and 100 characters. Empty string is allowed. /// - public required string ResourcePath { get; init; } + public required string ResourcePath { get; set; } /// - /// Gets the user-defined type of this occurrence. + /// Gets or sets the user-defined type of this occurrence. /// /// /// Used to differentiate between different kinds of occurrences (e.g., "appointment", "meeting"). /// Must be between 1 and 100 characters. Empty string is NOT allowed. /// - public required string Type { get; init; } + public required string Type { get; set; } /// /// Gets or sets the UTC timestamp when this occurrence starts. diff --git a/src/RecurringThings/Domain/OccurrenceException.cs b/src/RecurringThings/Domain/OccurrenceException.cs index ed7e67c..a28ab14 100644 --- a/src/RecurringThings/Domain/OccurrenceException.cs +++ b/src/RecurringThings/Domain/OccurrenceException.cs @@ -16,7 +16,7 @@ namespace RecurringThings.Domain; /// Once an exception is created, the occurrence cannot be restored through the API. /// /// -public sealed class OccurrenceException +internal sealed class OccurrenceException { /// /// Gets the unique identifier for this exception. diff --git a/src/RecurringThings/Domain/OccurrenceOverride.cs b/src/RecurringThings/Domain/OccurrenceOverride.cs index 3f6a5de..03d704b 100644 --- a/src/RecurringThings/Domain/OccurrenceOverride.cs +++ b/src/RecurringThings/Domain/OccurrenceOverride.cs @@ -20,7 +20,7 @@ namespace RecurringThings.Domain; /// When StartTime or Duration changes, is automatically recomputed. /// /// -public sealed class OccurrenceOverride +internal sealed class OccurrenceOverride { private DateTime _startTime; private TimeSpan _duration; diff --git a/src/RecurringThings/Domain/Recurrence.cs b/src/RecurringThings/Domain/Recurrence.cs index 01fd63f..e59aea0 100644 --- a/src/RecurringThings/Domain/Recurrence.cs +++ b/src/RecurringThings/Domain/Recurrence.cs @@ -16,7 +16,7 @@ namespace RecurringThings.Domain; /// To change other fields, delete and recreate the recurrence. /// /// -public sealed class Recurrence +internal sealed class Recurrence { /// /// Gets the unique identifier for this recurrence. diff --git a/src/RecurringThings/Engine/IRecurrenceEngine.cs b/src/RecurringThings/Engine/IRecurrenceEngine.cs index b3c3e08..be15cc4 100644 --- a/src/RecurringThings/Engine/IRecurrenceEngine.cs +++ b/src/RecurringThings/Engine/IRecurrenceEngine.cs @@ -4,7 +4,6 @@ namespace RecurringThings.Engine; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using RecurringThings.Domain; using RecurringThings.Models; using Transactional.Abstractions; @@ -118,7 +117,7 @@ IAsyncEnumerable GetRecurrencesAsync( /// Optional user-defined key-value metadata. /// Optional transaction context. /// A token to cancel the operation. - /// The created . + /// A representing the created recurrence with . /// /// Thrown when validation fails (invalid RRule, missing UNTIL, COUNT used, field length violations, etc.). /// @@ -131,7 +130,7 @@ IAsyncEnumerable GetRecurrencesAsync( /// }; /// pattern.ByDay.Add(new WeekDay(DayOfWeek.Monday)); /// - /// var recurrence = await engine.CreateRecurrenceAsync( + /// var entry = await engine.CreateRecurrenceAsync( /// organization: "tenant1", /// resourcePath: "user123/calendar", /// type: "appointment", @@ -141,7 +140,7 @@ IAsyncEnumerable GetRecurrencesAsync( /// timeZone: "America/New_York"); /// /// - Task CreateRecurrenceAsync( + Task CreateRecurrenceAsync( string organization, string resourcePath, string type, @@ -165,7 +164,7 @@ Task CreateRecurrenceAsync( /// Optional user-defined key-value metadata. /// Optional transaction context. /// A token to cancel the operation. - /// The created . + /// A representing the created occurrence with . /// /// EndTime is automatically computed as StartTime + Duration. /// @@ -174,7 +173,7 @@ Task CreateRecurrenceAsync( /// /// /// - /// var occurrence = await engine.CreateOccurrenceAsync( + /// var entry = await engine.CreateOccurrenceAsync( /// organization: "tenant1", /// resourcePath: "user123/calendar", /// type: "meeting", @@ -183,7 +182,7 @@ Task CreateRecurrenceAsync( /// timeZone: "America/New_York"); /// /// - Task CreateOccurrenceAsync( + Task CreateOccurrenceAsync( string organization, string resourcePath, string type, diff --git a/src/RecurringThings/Engine/RecurrenceEngine.cs b/src/RecurringThings/Engine/RecurrenceEngine.cs index cffd635..cde07b6 100644 --- a/src/RecurringThings/Engine/RecurrenceEngine.cs +++ b/src/RecurringThings/Engine/RecurrenceEngine.cs @@ -31,7 +31,7 @@ namespace RecurringThings.Engine; /// Streams results as objects /// /// -public sealed class RecurrenceEngine : IRecurrenceEngine +internal sealed class RecurrenceEngine : IRecurrenceEngine { private readonly IRecurrenceRepository _recurrenceRepository; private readonly IOccurrenceRepository _occurrenceRepository; @@ -211,7 +211,7 @@ public async IAsyncEnumerable GetOccurrencesAsync( } /// - public async Task CreateRecurrenceAsync( + public async Task CreateRecurrenceAsync( string organization, string resourcePath, string type, @@ -248,14 +248,17 @@ public async Task CreateRecurrenceAsync( }; // Persist via repository - return await _recurrenceRepository.CreateAsync( + var created = await _recurrenceRepository.CreateAsync( recurrence, transactionContext, cancellationToken).ConfigureAwait(false); + + // Convert to CalendarEntry + return CreateRecurrenceEntry(created); } /// - public async Task CreateOccurrenceAsync( + public async Task CreateOccurrenceAsync( string organization, string resourcePath, string type, @@ -287,10 +290,13 @@ public async Task CreateRecurrenceAsync( occurrence.Initialize(startTimeUtc, duration); // Persist via repository - return await _occurrenceRepository.CreateAsync( + var created = await _occurrenceRepository.CreateAsync( occurrence, transactionContext, cancellationToken).ConfigureAwait(false); + + // Convert to CalendarEntry + return CreateStandaloneEntry(created); } /// @@ -539,17 +545,20 @@ public async Task UpdateOccurrenceAsync( } /// - /// Updates a standalone occurrence. StartTime, Duration, and Extensions are mutable. + /// Updates a standalone occurrence. StartTime, Duration, Extensions, Type, and ResourcePath are mutable. /// private async Task UpdateStandaloneOccurrenceAsync( CalendarEntry entry, ITransactionContext? transactionContext, CancellationToken cancellationToken) { + // Use OriginalResourcePath if ResourcePath was modified + var lookupResourcePath = entry.OriginalResourcePath ?? entry.ResourcePath; + var occurrence = await _occurrenceRepository.GetByIdAsync( entry.OccurrenceId!.Value, entry.Organization, - entry.ResourcePath, + lookupResourcePath, transactionContext, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException( $"Occurrence with ID '{entry.OccurrenceId}' not found."); @@ -561,6 +570,8 @@ private async Task UpdateStandaloneOccurrenceAsync( occurrence.StartTime = entry.StartTime; occurrence.Duration = entry.Duration; occurrence.Extensions = entry.Extensions; + occurrence.Type = entry.Type; + occurrence.ResourcePath = entry.ResourcePath; var updated = await _occurrenceRepository.UpdateAsync( occurrence, @@ -581,18 +592,6 @@ private static void ValidateImmutableOccurrenceFields(CalendarEntry entry, Domai "Cannot modify Organization. This field is immutable after creation."); } - if (entry.ResourcePath != existing.ResourcePath) - { - throw new InvalidOperationException( - "Cannot modify ResourcePath. This field is immutable after creation."); - } - - if (entry.Type != existing.Type) - { - throw new InvalidOperationException( - "Cannot modify Type. This field is immutable after creation."); - } - if (entry.TimeZone != existing.TimeZone) { throw new InvalidOperationException( @@ -611,10 +610,13 @@ private async Task CreateOverrideForVirtualizedOccurrenceAsync( var recurrenceId = entry.RecurrenceId ?? throw new InvalidOperationException("Cannot create override: RecurrenceId is missing."); + // Use OriginalResourcePath if ResourcePath was modified (though this should fail validation) + var lookupResourcePath = entry.OriginalResourcePath ?? entry.ResourcePath; + var recurrence = await _recurrenceRepository.GetByIdAsync( recurrenceId, entry.Organization, - entry.ResourcePath, + lookupResourcePath, transactionContext, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException( $"Parent recurrence with ID '{recurrenceId}' not found."); @@ -662,10 +664,13 @@ private async Task UpdateVirtualizedOccurrenceWithOverrideAsync( ITransactionContext? transactionContext, CancellationToken cancellationToken) { + // Use OriginalResourcePath if ResourcePath was modified (though this should fail validation) + var lookupResourcePath = entry.OriginalResourcePath ?? entry.ResourcePath; + var @override = await _overrideRepository.GetByIdAsync( entry.OverrideId!.Value, entry.Organization, - entry.ResourcePath, + lookupResourcePath, transactionContext, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException( $"Override with ID '{entry.OverrideId}' not found."); @@ -674,7 +679,7 @@ private async Task UpdateVirtualizedOccurrenceWithOverrideAsync( var recurrence = await _recurrenceRepository.GetByIdAsync( @override.RecurrenceId, entry.Organization, - entry.ResourcePath, + lookupResourcePath, transactionContext, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException( $"Parent recurrence with ID '{@override.RecurrenceId}' not found."); diff --git a/src/RecurringThings/Models/CalendarEntry.cs b/src/RecurringThings/Models/CalendarEntry.cs index 76f948f..85e1b73 100644 --- a/src/RecurringThings/Models/CalendarEntry.cs +++ b/src/RecurringThings/Models/CalendarEntry.cs @@ -28,28 +28,72 @@ namespace RecurringThings.Models; /// /// is never set in query results because excepted occurrences are not returned. /// +/// +/// Mutable properties: +/// +/// +/// , , - mutable on standalone occurrences and virtualized occurrences +/// , - mutable only on standalone occurrences and recurrences (not on virtualized occurrences) +/// /// public sealed class CalendarEntry { + private string _resourcePath = null!; + /// - /// Gets or sets the tenant identifier for multi-tenant isolation. + /// Gets the tenant identifier for multi-tenant isolation. /// - public required string Organization { get; set; } + /// + /// This property is immutable after creation. + /// + public required string Organization { get; init; } /// /// Gets or sets the hierarchical resource scope. /// - public required string ResourcePath { get; set; } + /// + /// + /// This property is mutable on standalone occurrences and recurrences. + /// Changing ResourcePath on virtualized occurrences is not allowed. + /// + /// + public required string ResourcePath + { + get => _resourcePath; + set + { + // Track original value for database lookups + OriginalResourcePath ??= _resourcePath; + _resourcePath = value; + } + } + + /// + /// Gets the original resource path value when the entry was loaded. + /// + /// + /// Used internally to locate the record in the database when ResourcePath has been modified. + /// + internal string? OriginalResourcePath { get; private set; } /// /// Gets or sets the user-defined type of this entry. /// + /// + /// + /// This property is mutable on standalone occurrences and recurrences. + /// Changing Type on virtualized occurrences is not allowed. + /// + /// public required string Type { get; set; } /// - /// Gets or sets the type of this calendar entry. + /// Gets the type of this calendar entry. /// - public CalendarEntryType EntryType { get; set; } + /// + /// This property is immutable after creation. + /// + public CalendarEntryType EntryType { get; init; } /// /// Gets or sets the local timestamp when this entry starts. @@ -60,12 +104,17 @@ public sealed class CalendarEntry public DateTime StartTime { get; set; } /// - /// Gets or sets the local timestamp when this entry ends. + /// Gets the local timestamp when this entry ends. /// /// + /// /// The time is in the local timezone specified by . + /// + /// + /// EndTime is computed as StartTime + Duration and cannot be set directly. + /// /// - public DateTime EndTime { get; set; } + public DateTime EndTime { get; internal set; } /// /// Gets or sets the duration of this entry. @@ -73,9 +122,12 @@ public sealed class CalendarEntry public TimeSpan Duration { get; set; } /// - /// Gets or sets the IANA time zone identifier. + /// Gets the IANA time zone identifier. /// - public required string TimeZone { get; set; } + /// + /// This property is immutable after creation. + /// + public required string TimeZone { get; init; } /// /// Gets or sets the user-defined key-value metadata. @@ -83,39 +135,59 @@ public sealed class CalendarEntry public Dictionary? Extensions { get; set; } /// - /// Gets or sets the recurrence ID. + /// Gets the recurrence ID. /// /// + /// /// Set when is /// or . + /// + /// + /// This property is immutable after creation. + /// /// - public Guid? RecurrenceId { get; set; } + public Guid? RecurrenceId { get; init; } /// - /// Gets or sets the standalone occurrence ID. + /// Gets the standalone occurrence ID. /// /// + /// /// Set only when is . + /// + /// + /// This property is immutable after creation. + /// /// - public Guid? OccurrenceId { get; set; } + public Guid? OccurrenceId { get; init; } /// - /// Gets or sets the override ID if this virtualized occurrence has been modified. + /// Gets the override ID if this virtualized occurrence has been modified. /// /// + /// /// Set when this is a virtualized occurrence that has an override applied. /// Check for a convenient boolean check. + /// + /// + /// This property is immutable after creation. + /// /// - public Guid? OverrideId { get; set; } + public Guid? OverrideId { get; init; } /// - /// Gets or sets the exception ID. + /// Gets the exception ID. /// /// + /// /// This property is never set in query results because excepted (deleted) occurrences /// are not returned by queries. + /// + /// + /// This property is immutable after creation. + /// /// - public Guid? ExceptionId { get; set; } + public Guid? ExceptionId { get; init; } /// /// Gets a value indicating whether this entry has an override applied. @@ -127,17 +199,22 @@ public sealed class CalendarEntry public bool IsOverridden => OverrideId.HasValue; /// - /// Gets or sets the recurrence details. + /// Gets the recurrence details. /// /// + /// /// Populated when is /// or . Contains the RRule that defines or /// generated this entry. + /// + /// + /// This property is immutable after creation. + /// /// - public RecurrenceDetails? RecurrenceDetails { get; set; } + public RecurrenceDetails? RecurrenceDetails { get; init; } /// - /// Gets or sets the original values before an override was applied. + /// Gets the original values before an override was applied. /// /// /// @@ -147,6 +224,9 @@ public sealed class CalendarEntry /// When an override is applied to a virtualized occurrence, this property contains /// the original start time, duration, and extensions before modification. /// + /// + /// This property is immutable after creation. + /// /// - public OriginalDetails? Original { get; set; } + public OriginalDetails? Original { get; init; } } diff --git a/src/RecurringThings/RecurringThings.csproj b/src/RecurringThings/RecurringThings.csproj index 690ab9d..7301a8a 100644 --- a/src/RecurringThings/RecurringThings.csproj +++ b/src/RecurringThings/RecurringThings.csproj @@ -10,8 +10,8 @@ RecurringThings - RecurringThings Contributors - A .NET library for managing recurring events with on-demand virtualization, supporting MongoDB and PostgreSQL persistence. + Miguel Tremblay + A .NET library for managing recurring events with efficient on-demand virtualization. https://github.com/ChuckNovice/RecurringThings Apache-2.0 README.md @@ -26,7 +26,10 @@ + + + diff --git a/src/RecurringThings/Repository/IOccurrenceExceptionRepository.cs b/src/RecurringThings/Repository/IOccurrenceExceptionRepository.cs index 34b3e1e..399b120 100644 --- a/src/RecurringThings/Repository/IOccurrenceExceptionRepository.cs +++ b/src/RecurringThings/Repository/IOccurrenceExceptionRepository.cs @@ -14,7 +14,7 @@ namespace RecurringThings.Repository; /// Occurrence exceptions are used to cancel specific virtualized occurrences /// from a recurrence pattern without deleting the entire recurrence. /// -public interface IOccurrenceExceptionRepository +internal interface IOccurrenceExceptionRepository { /// /// Creates a new occurrence exception. diff --git a/src/RecurringThings/Repository/IOccurrenceOverrideRepository.cs b/src/RecurringThings/Repository/IOccurrenceOverrideRepository.cs index f79c1fa..8e266a4 100644 --- a/src/RecurringThings/Repository/IOccurrenceOverrideRepository.cs +++ b/src/RecurringThings/Repository/IOccurrenceOverrideRepository.cs @@ -14,7 +14,7 @@ namespace RecurringThings.Repository; /// Occurrence overrides are used to modify specific virtualized occurrences /// from a recurrence pattern (e.g., change time, duration, or metadata). /// -public interface IOccurrenceOverrideRepository +internal interface IOccurrenceOverrideRepository { /// /// Creates a new occurrence override. diff --git a/src/RecurringThings/Repository/IOccurrenceRepository.cs b/src/RecurringThings/Repository/IOccurrenceRepository.cs index a4ea7c9..5b83d71 100644 --- a/src/RecurringThings/Repository/IOccurrenceRepository.cs +++ b/src/RecurringThings/Repository/IOccurrenceRepository.cs @@ -10,7 +10,7 @@ namespace RecurringThings.Repository; /// /// Repository interface for managing standalone entities. /// -public interface IOccurrenceRepository +internal interface IOccurrenceRepository { /// /// Creates a new standalone occurrence. diff --git a/src/RecurringThings/Repository/IRecurrenceRepository.cs b/src/RecurringThings/Repository/IRecurrenceRepository.cs index 67edb1c..b0d1718 100644 --- a/src/RecurringThings/Repository/IRecurrenceRepository.cs +++ b/src/RecurringThings/Repository/IRecurrenceRepository.cs @@ -10,7 +10,7 @@ namespace RecurringThings.Repository; /// /// Repository interface for managing entities. /// -public interface IRecurrenceRepository +internal interface IRecurrenceRepository { /// /// Creates a new recurrence. diff --git a/src/RecurringThings/Validation/Validator.cs b/src/RecurringThings/Validation/Validator.cs index 58747ac..358d8f2 100644 --- a/src/RecurringThings/Validation/Validator.cs +++ b/src/RecurringThings/Validation/Validator.cs @@ -21,7 +21,7 @@ public static partial class Validator /// /// Thrown when the child entity has a different Organization or ResourcePath than the parent. /// - public static void ValidateTenantScope( + internal static void ValidateTenantScope( Recurrence parentRecurrence, string childOrganization, string childResourcePath) diff --git a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs index dc36794..2fce028 100644 --- a/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs +++ b/tests/RecurringThings.Tests/Engine/RecurrenceEngineCrudTests.cs @@ -68,15 +68,15 @@ public async Task CreateRecurrenceAsync_WithValidParameters_ReturnsRecurrenceWit // Assert result.Should().NotBeNull(); - result.Id.Should().NotBe(Guid.Empty); + result.RecurrenceId.Should().NotBe(Guid.Empty); + result.EntryType.Should().Be(CalendarEntryType.Recurrence); result.Organization.Should().Be(TestOrganization); result.ResourcePath.Should().Be(TestResourcePath); result.Type.Should().Be(TestType); result.StartTime.Should().Be(startTime); result.Duration.Should().Be(duration); - // 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(TestRRule); + result.RecurrenceDetails.Should().NotBeNull(); + result.RecurrenceDetails!.RRule.Should().Be(TestRRule); result.TimeZone.Should().Be(TestTimeZone); result.Extensions.Should().BeEquivalentTo(extensions); } @@ -210,7 +210,8 @@ public async Task CreateOccurrenceAsync_WithValidParameters_ReturnsOccurrenceWit // Assert result.Should().NotBeNull(); - result.Id.Should().NotBe(Guid.Empty); + result.OccurrenceId.Should().NotBe(Guid.Empty); + result.EntryType.Should().Be(CalendarEntryType.Standalone); result.Organization.Should().Be(TestOrganization); result.StartTime.Should().Be(startTime); result.Duration.Should().Be(duration); @@ -338,21 +339,26 @@ public async Task UpdateAsync_StandaloneOccurrence_UpdatesStartTimeDurationExten } [Fact] - public async Task UpdateAsync_StandaloneOccurrenceWithImmutableTypeChange_ThrowsInvalidOperationException() + public async Task UpdateAsync_StandaloneOccurrenceWithTypeChange_UpdatesTypeSuccessfully() { // Arrange var occurrenceId = Guid.NewGuid(); var existingOccurrence = CreateOccurrence(occurrenceId); + var newType = "different-type"; _occurrenceRepo .Setup(r => r.GetByIdAsync(occurrenceId, TestOrganization, TestResourcePath, null, default)) .ReturnsAsync(existingOccurrence); + _occurrenceRepo + .Setup(r => r.UpdateAsync(It.IsAny(), null, default)) + .ReturnsAsync((Occurrence o, ITransactionContext? _, CancellationToken _) => o); + var entry = new CalendarEntry { Organization = TestOrganization, ResourcePath = TestResourcePath, - Type = "different-type", // Changed! + Type = newType, // Changed - Type is mutable on standalone occurrences StartTime = existingOccurrence.StartTime, Duration = existingOccurrence.Duration, TimeZone = TestTimeZone, @@ -360,11 +366,14 @@ public async Task UpdateAsync_StandaloneOccurrenceWithImmutableTypeChange_Throws }; // Act - var act = () => _engine.UpdateOccurrenceAsync(entry); + var result = await _engine.UpdateOccurrenceAsync(entry); // Assert - await act.Should().ThrowAsync() - .WithMessage("*Type*immutable*"); + result.Type.Should().Be(newType); + _occurrenceRepo.Verify(r => r.UpdateAsync( + It.Is(o => o.Type == newType), + null, + default), Times.Once); } #endregion