using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Net.ServerSentEvents; namespace ModelContextProtocol.Tests; /// /// Tests for . /// public class DistributedCacheEventStreamStoreTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; private static IDistributedCache CreateMemoryCache() { var options = Options.Create(new MemoryDistributedCacheOptions()); return new MemoryDistributedCache(options); } [Fact] public void Constructor_ThrowsArgumentNullException_WhenCacheIsNull() { Assert.Throws("cache", () => new DistributedCacheEventStreamStore(null!)); } [Fact] public async Task CreateStreamAsync_ThrowsArgumentNullException_WhenOptionsIsNull() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); // Act & Assert await Assert.ThrowsAsync("options", async () => await store.CreateStreamAsync(null!, CancellationToken)); } [Fact] public async Task WriteEventAsync_AssignsUniqueEventId_WhenItemHasNoEventId() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); var item = new SseItem(null); // Act var result = await writer.WriteEventAsync(item, CancellationToken); // Assert Assert.NotNull(result.EventId); Assert.NotEmpty(result.EventId); } [Fact] public async Task WriteEventAsync_SkipsAssigningEventId_WhenItemAlreadyHasEventId() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); var existingEventId = "existing-event-id"; var item = new SseItem(null) { EventId = existingEventId }; // Act var result = await writer.WriteEventAsync(item, CancellationToken); // Assert Assert.Equal(existingEventId, result.EventId); } [Fact] public async Task WriteEventAsync_PreservesDataProperty_InReturnedItem() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); var message = new JsonRpcNotification { Method = "test/notification" }; var item = new SseItem(message); // Act var result = await writer.WriteEventAsync(item, CancellationToken); // Assert - Data should be preserved in the returned item (same reference) Assert.Same(message, result.Data); } [Fact] public async Task WriteEventAsync_PreservesEventTypeProperty_InReturnedItem() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); var item = new SseItem(null, "custom-event-type"); // Act var result = await writer.WriteEventAsync(item, CancellationToken); // Assert Assert.Equal("custom-event-type", result.EventType); } [Fact] public async Task WriteEventAsync_PreservesReconnectionIntervalProperty_InStoredEvent() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var expectedInterval = TimeSpan.FromSeconds(5); var item = new SseItem(null) { ReconnectionInterval = expectedInterval }; // Act var result = await writer.WriteEventAsync(item, CancellationToken); // Assert - ReconnectionInterval should be preserved in returned item Assert.Equal(expectedInterval, result.ReconnectionInterval); // Get a reader and verify ReconnectionInterval is preserved after round-trip var reader = await store.GetStreamReaderAsync(result.EventId!, CancellationToken); Assert.NotNull(reader); var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Reader should not return the event we just wrote (it starts after lastEventId) Assert.Empty(events); // Write another event and verify it can be read with correct ReconnectionInterval var secondItem = new SseItem(null) { ReconnectionInterval = TimeSpan.FromSeconds(10) }; _ = await writer.WriteEventAsync(secondItem, CancellationToken); // Re-fetch reader using the first event ID to get the second event reader = await store.GetStreamReaderAsync(result.EventId!, CancellationToken); Assert.NotNull(reader); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } Assert.Single(events); Assert.Equal(TimeSpan.FromSeconds(10), events[0].ReconnectionInterval); } [Fact] public async Task WriteEventAsync_HandlesNullReconnectionInterval_InStoredEvent() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write an event WITH a reconnection interval first var firstItem = new SseItem(null) { ReconnectionInterval = TimeSpan.FromSeconds(5) }; var firstResult = await writer.WriteEventAsync(firstItem, CancellationToken); // Write an event WITHOUT a reconnection interval var secondItem = new SseItem(null); var secondResult = await writer.WriteEventAsync(secondItem, CancellationToken); Assert.Null(secondResult.ReconnectionInterval); // Get a reader starting after the first event var reader = await store.GetStreamReaderAsync(firstResult.EventId!, CancellationToken); Assert.NotNull(reader); var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Should get the second event with null ReconnectionInterval Assert.Single(events); Assert.Null(events[0].ReconnectionInterval); } [Fact] public async Task WriteEventAsync_HandlesNullData_AssignsEventIdAndStoresEvent() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var item = new SseItem(null); // Act var result = await writer.WriteEventAsync(item, CancellationToken); // Assert - Event ID should be assigned Assert.NotNull(result.EventId); // Assert - Event should be retrievable var reader = await store.GetStreamReaderAsync(result.EventId, CancellationToken); Assert.NotNull(reader); } [Fact] public async Task WriteEventAsync_StoresEventWithCorrectSlidingExpiration() { // Arrange - Use a mock cache to verify expiration options var mockCache = new TestDistributedCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { EventSlidingExpiration = TimeSpan.FromMinutes(15) }; var store = new DistributedCacheEventStreamStore(mockCache, customOptions); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); var item = new SseItem(null); // Act await writer.WriteEventAsync(item, CancellationToken); // Assert - Verify at least one call used the expected sliding expiration Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("event:") && call.Options.SlidingExpiration == TimeSpan.FromMinutes(15)); } [Fact] public async Task WriteEventAsync_StoresEventWithCorrectAbsoluteExpiration() { // Arrange var mockCache = new TestDistributedCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { EventAbsoluteExpiration = TimeSpan.FromHours(3) }; var store = new DistributedCacheEventStreamStore(mockCache, customOptions); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); var item = new SseItem(null); // Act await writer.WriteEventAsync(item, CancellationToken); // Assert Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("event:") && call.Options.AbsoluteExpirationRelativeToNow == TimeSpan.FromHours(3)); } [Fact] public async Task WriteEventAsync_UpdatesStreamMetadata_AfterEachWrite() { // Arrange var mockCache = new TestDistributedCache(); var store = new DistributedCacheEventStreamStore(mockCache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); var item = new SseItem(null); // Act await writer.WriteEventAsync(item, CancellationToken); // Assert - Metadata should have been updated Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("meta:")); } [Fact] public async Task SetModeAsync_PersistsModeChangeToMetadata() { // Arrange var mockCache = new TestDistributedCache(); var store = new DistributedCacheEventStreamStore(mockCache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); mockCache.SetCalls.Clear(); // Clear calls from CreateStreamAsync setup // Act await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken); // Assert - Metadata should have been updated with the new mode Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("meta:")); } [Fact] public async Task SetModeAsync_ModeChangeReflectedInReader() { // Arrange var cache = CreateMemoryCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(10) }; var store = new DistributedCacheEventStreamStore(cache, customOptions); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write an event to have something to read var item = new SseItem(new JsonRpcNotification { Method = "test" }); var writtenItem = await writer.WriteEventAsync(item, CancellationToken); // Get a reader based on the event ID (starting at sequence 1, reader will wait for seq 2+) var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken); Assert.NotNull(reader); // Act - Change mode to Polling while reader exists await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken); // Assert - Reader should complete immediately in polling mode (no new events to read) using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(cts.Token)) { events.Add(evt); } // In polling mode, reader should complete without waiting for new events Assert.Empty(events); // No events after the one we used to create the reader } [Fact] public async Task DisposeAsync_MarksStreamAsCompleted() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write an event so we can get a reader var item = new SseItem(null); var writtenItem = await writer.WriteEventAsync(item, CancellationToken); // Act await writer.DisposeAsync(); // Assert - Reader should see the stream as completed and exit immediately var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken); Assert.NotNull(reader); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(cts.Token)) { events.Add(evt); } // The reader should complete without waiting for new events because stream is completed Assert.Empty(events); // No new events after the one we used to create the reader } [Fact] public async Task DisposeAsync_IsIdempotent() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Act - Call DisposeAsync multiple times await writer.DisposeAsync(); await writer.DisposeAsync(); await writer.DisposeAsync(); // Assert - No exception thrown, operation is idempotent // If we got here without exception, the test passes } [Fact] public async Task DisposeAsync_UpdatesMetadata_WithIsCompletedFlag() { // Arrange var mockCache = new TestDistributedCache(); var store = new DistributedCacheEventStreamStore(mockCache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); mockCache.SetCalls.Clear(); // Clear calls from CreateStreamAsync // Act await writer.DisposeAsync(); // Assert - Metadata should have been updated Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("meta:")); } [Fact] public async Task GetStreamReaderAsync_ThrowsArgumentNullException_WhenLastEventIdIsNull() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); // Act & Assert await Assert.ThrowsAsync("lastEventId", async () => await store.GetStreamReaderAsync(null!, CancellationToken)); } [Fact] public async Task GetStreamReaderAsync_ReturnsNull_WhenEventIdIsUnparseable() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); // Act - Try various invalid event ID formats var result1 = await store.GetStreamReaderAsync("invalid-format", CancellationToken); var result2 = await store.GetStreamReaderAsync("only:two:parts:here", CancellationToken); var result3 = await store.GetStreamReaderAsync("", CancellationToken); // Assert Assert.Null(result1); Assert.Null(result2); Assert.Null(result3); } [Fact] public async Task GetStreamReaderAsync_ReturnsNull_WhenStreamMetadataDoesNotExist() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); // Create a valid-looking event ID for a stream that doesn't exist var fakeEventId = DistributedCacheEventIdFormatter.Format("nonexistent-session", "nonexistent-stream", 1); // Act var reader = await store.GetStreamReaderAsync(fakeEventId, CancellationToken); // Assert Assert.Null(reader); } [Fact] public async Task GetStreamReaderAsync_ReturnsReaderWithCorrectSessionIdAndStreamId() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "my-session", StreamId = "my-stream", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write an event to get a valid event ID var item = new SseItem(null); var writtenItem = await writer.WriteEventAsync(item, CancellationToken); // Act var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken); // Assert Assert.NotNull(reader); Assert.Equal("my-session", reader.SessionId); Assert.Equal("my-stream", reader.StreamId); } [Fact] public async Task ReadEventsAsync_ReturnsEventsInOrder() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write multiple events var event1 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method1" }), CancellationToken); var event2 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method2" }), CancellationToken); var event3 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method3" }), CancellationToken); // Create a reader starting from before the first event (use a fake event ID with sequence 0) var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken); Assert.NotNull(reader); // Act var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Assert - Events should be in order Assert.Equal(3, events.Count); Assert.Equal(event1.EventId, events[0].EventId); Assert.Equal(event2.EventId, events[1].EventId); Assert.Equal(event3.EventId, events[2].EventId); } [Fact] public async Task ReadEventsAsync_ReturnsEmpty_WhenNoNewEventsExist() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write one event var writtenItem = await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Create a reader starting from the last event (so there are no new events to read) var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken); Assert.NotNull(reader); // Act var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Assert Assert.Empty(events); } [Fact] public async Task ReadEventsAsync_PreservesCorrectDataEventTypeAndEventId() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var message = new JsonRpcNotification { Method = "test/method" }; var writtenItem = await writer.WriteEventAsync(new SseItem(message, "custom-event-type"), CancellationToken); // Create a reader starting from before the event var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken); Assert.NotNull(reader); // Act var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Assert Assert.Single(events); var readEvent = events[0]; Assert.Equal(writtenItem.EventId, readEvent.EventId); Assert.Equal("custom-event-type", readEvent.EventType); var readMessage = Assert.IsType(readEvent.Data); Assert.Equal("test/method", readMessage.Method); } [Fact] public async Task ReadEventsAsync_HandlesNullData() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var writtenItem = await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Create a reader starting from before the event var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken); Assert.NotNull(reader); // Act var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Assert Assert.Single(events); Assert.Null(events[0].Data); Assert.Equal(writtenItem.EventId, events[0].EventId); } [Fact] public async Task ReadEventsAsync_InPollingMode_CompletesImmediatelyAfterReturningAvailableEvents() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write events await writer.WriteEventAsync(new SseItem(null), CancellationToken); await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Create a reader from sequence 0 var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken); Assert.NotNull(reader); // Act - Should complete quickly without waiting for new events var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } stopwatch.Stop(); // Assert - Should have returned both events and completed quickly Assert.Equal(2, events.Count); Assert.True(stopwatch.ElapsedMilliseconds < 500, $"Polling mode should complete quickly, took {stopwatch.ElapsedMilliseconds}ms"); } [Fact] public async Task ReadEventsAsync_InPollingMode_ReturnsOnlyEventsAfterLastEventId() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write 3 events var event1 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var event2 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var event3 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Create a reader starting from event2 (should only return event3) var reader = await store.GetStreamReaderAsync(event2.EventId!, CancellationToken); Assert.NotNull(reader); // Act var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Assert - Only event3 should be returned Assert.Single(events); Assert.Equal(event3.EventId, events[0].EventId); } [Fact] public async Task ReadEventsAsync_InPollingMode_ReturnsEmptyIfNoNewEvents() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write one event and create a reader from that event (no events after it) var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken); Assert.NotNull(reader); // Act var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Assert - No new events should be returned Assert.Empty(events); } [Fact] public async Task ReadEventsAsync_InPollingMode_DoesNotWaitForNewEvents() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write one event so we have a valid event ID, then create reader from it var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken); Assert.NotNull(reader); // Act - Should complete immediately without waiting (no new events after the one we started from) var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } stopwatch.Stop(); // Assert - Should complete quickly with no events Assert.Empty(events); Assert.True(stopwatch.ElapsedMilliseconds < 500, $"Polling mode should complete quickly, took {stopwatch.ElapsedMilliseconds}ms"); } [Fact] public async Task ReadEventsAsync_InStreamingMode_WaitsForNewEvents() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50) }); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write one event so we have a valid event ID var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken); Assert.NotNull(reader); // Act - Start reading and then write a new event using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(10)); var events = new List>(); var readTask = Task.Run(async () => { await foreach (var evt in reader.ReadEventsAsync(cts.Token)) { events.Add(evt); if (events.Count >= 1) { // Got the event we were waiting for, cancel to stop await cts.CancelAsync(); } } }, CancellationToken); // Write a new event - the reader should pick it up since it's in streaming mode // and won't complete until cancelled or the stream is disposed var newEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Wait for read to complete (either event received or timeout) try { await readTask; } catch (OperationCanceledException) { // Expected when we cancel after receiving event } // Assert - Should have received the new event Assert.Single(events); Assert.Equal(newEvent.EventId, events[0].EventId); } [Fact] public async Task ReadEventsAsync_InStreamingMode_YieldsNewlyWrittenEvents() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50) }); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write initial event var initialEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var reader = await store.GetStreamReaderAsync(initialEvent.EventId!, CancellationToken); Assert.NotNull(reader); // Act - Write multiple events while reader is active using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(10)); var events = new List>(); var readTask = Task.Run(async () => { await foreach (var evt in reader.ReadEventsAsync(cts.Token)) { events.Add(evt); if (events.Count >= 3) { await cts.CancelAsync(); } } }, CancellationToken); // Write 3 new events - the reader should pick them up since it's in streaming mode var event1 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var event2 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var event3 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); try { await readTask; } catch (OperationCanceledException) { // Expected } // Assert - Should have received all 3 events in order Assert.Equal(3, events.Count); Assert.Equal(event1.EventId, events[0].EventId); Assert.Equal(event2.EventId, events[1].EventId); Assert.Equal(event3.EventId, events[2].EventId); } [Fact] public async Task ReadEventsAsync_InStreamingMode_CompletesWhenStreamIsDisposed() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50) }); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write event to create a valid reader var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken); Assert.NotNull(reader); // Act - Start reading, then dispose the stream var readTask = Task.Run(async () => { await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { } }, CancellationToken); // Dispose the writer - the reader should detect this and exit gracefully await writer.DisposeAsync(); // Assert - The read should complete gracefully within timeout using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); await readTask.WaitAsync(timeoutCts.Token); } [Fact] public async Task ReadEventsAsync_InStreamingMode_RespectsCancellation() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50) }); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write event to create a valid reader var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken); Assert.NotNull(reader); // Act - Start reading and then cancel using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); var events = new List>(); var messageReceivedTcs = new TaskCompletionSource(); var continueReadingTcs = new TaskCompletionSource(); OperationCanceledException? capturedException = null; var readTask = Task.Run(async () => { try { await foreach (var evt in reader.ReadEventsAsync(cts.Token)) { events.Add(evt); messageReceivedTcs.SetResult(true); await continueReadingTcs.Task; } } catch (OperationCanceledException ex) { capturedException = ex; } }, CancellationToken); // Write a message for the reader to consume await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Wait for the first message to be received await messageReceivedTcs.Task; // Cancel so that ReadEventsAsync throws before reading the next message await cts.CancelAsync(); // Allow the message reader to continue continueReadingTcs.SetResult(true); // Wait for read task to complete await readTask; Assert.Single(events); Assert.NotNull(capturedException); } [Fact] public async Task ReadEventsAsync_RespectsModeSwitchFromStreamingToPolling() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50) }); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write an event to create a valid reader var writtenEvent = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var reader = await store.GetStreamReaderAsync(writtenEvent.EventId!, CancellationToken); Assert.NotNull(reader); // Start reading in streaming mode (will wait for new events) using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(10)); var events = new List>(); var readCompleted = false; var readTask = Task.Run(async () => { await foreach (var evt in reader.ReadEventsAsync(cts.Token)) { events.Add(evt); } readCompleted = true; }, CancellationToken); // Switch to polling mode - the reader should detect this and exit await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken); // Assert - Read should complete within timeout after switching to polling mode await readTask.WaitAsync(cts.Token); Assert.True(readCompleted); Assert.Empty(events); // No new events were written after the one we used to create the reader } [Fact] public async Task ReadEventsAsync_PollingModeReturnsEventsThenCompletes() { // Arrange - Start in default mode, write some events, switch to polling, reader should return remaining events var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache, new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50) }); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming }, CancellationToken); // Write initial event and create reader from sequence 0 var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); // Write events first var event1 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var event2 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Switch to polling mode await writer.SetModeAsync(SseEventStreamMode.Polling, CancellationToken); // Get reader var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken); Assert.NotNull(reader); // Act - Read should return events and complete immediately var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } stopwatch.Stop(); // Assert Assert.Equal(2, events.Count); Assert.Equal(event1.EventId, events[0].EventId); Assert.Equal(event2.EventId, events[1].EventId); Assert.True(stopwatch.ElapsedMilliseconds < 500, $"Should complete quickly, took {stopwatch.ElapsedMilliseconds}ms"); } [Fact] public async Task MultipleStreams_AreIsolated_EventsDoNotLeakBetweenStreams() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); // Create two streams with different session/stream IDs var writer1 = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var writer2 = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-2", StreamId = "stream-2", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write events to each stream var event1 = await writer1.WriteEventAsync(new SseItem(null, "event-from-stream1"), CancellationToken); var event2 = await writer2.WriteEventAsync(new SseItem(null, "event-from-stream2"), CancellationToken); // Create readers for each stream from sequence 0 var start1 = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); var start2 = DistributedCacheEventIdFormatter.Format("session-2", "stream-2", 0); var reader1 = await store.GetStreamReaderAsync(start1, CancellationToken); var reader2 = await store.GetStreamReaderAsync(start2, CancellationToken); Assert.NotNull(reader1); Assert.NotNull(reader2); // Act - Read from each reader var events1 = new List>(); await foreach (var evt in reader1.ReadEventsAsync(CancellationToken)) { events1.Add(evt); } var events2 = new List>(); await foreach (var evt in reader2.ReadEventsAsync(CancellationToken)) { events2.Add(evt); } // Assert - Each reader should only see its own stream's events Assert.Single(events1); Assert.Equal("event-from-stream1", events1[0].EventType); Assert.Equal(event1.EventId, events1[0].EventId); Assert.Single(events2); Assert.Equal("event-from-stream2", events2[0].EventType); Assert.Equal(event2.EventId, events2[0].EventId); } [Fact] public async Task MultipleStreams_SameSession_DifferentStreamIds_AreIsolated() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); // Create two streams with same session but different stream IDs var writer1 = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "shared-session", StreamId = "stream-A", Mode = SseEventStreamMode.Polling }, CancellationToken); var writer2 = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "shared-session", StreamId = "stream-B", Mode = SseEventStreamMode.Polling }, CancellationToken); // Write events to each stream await writer1.WriteEventAsync(new SseItem(null, "from-A"), CancellationToken); await writer2.WriteEventAsync(new SseItem(null, "from-B"), CancellationToken); // Create readers from sequence 0 var reader1 = await store.GetStreamReaderAsync(DistributedCacheEventIdFormatter.Format("shared-session", "stream-A", 0), CancellationToken); var reader2 = await store.GetStreamReaderAsync(DistributedCacheEventIdFormatter.Format("shared-session", "stream-B", 0), CancellationToken); Assert.NotNull(reader1); Assert.NotNull(reader2); // Act var events1 = new List>(); await foreach (var evt in reader1.ReadEventsAsync(CancellationToken)) { events1.Add(evt); } var events2 = new List>(); await foreach (var evt in reader2.ReadEventsAsync(CancellationToken)) { events2.Add(evt); } // Assert Assert.Single(events1); Assert.Equal("from-A", events1[0].EventType); Assert.Single(events2); Assert.Equal("from-B", events2[0].EventType); } [Fact] public async Task EventIds_AreGloballyUnique_AcrossStreams() { // Arrange var cache = CreateMemoryCache(); var store = new DistributedCacheEventStreamStore(cache); var writer1 = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var writer2 = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-2", StreamId = "stream-2", Mode = SseEventStreamMode.Polling }, CancellationToken); // Act - Write events to each stream var event1a = await writer1.WriteEventAsync(new SseItem(null), CancellationToken); var event1b = await writer1.WriteEventAsync(new SseItem(null), CancellationToken); var event2a = await writer2.WriteEventAsync(new SseItem(null), CancellationToken); var event2b = await writer2.WriteEventAsync(new SseItem(null), CancellationToken); // Assert - All event IDs should be unique var allEventIds = new[] { event1a.EventId, event1b.EventId, event2a.EventId, event2b.EventId }; Assert.Equal(4, allEventIds.Distinct().Count()); } [Fact] public async Task WriteEventAsync_UsesConfiguredSlidingExpiration() { // Arrange var mockCache = new TestDistributedCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { EventSlidingExpiration = TimeSpan.FromMinutes(30) }; var store = new DistributedCacheEventStreamStore(mockCache, customOptions); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); mockCache.SetCalls.Clear(); // Act await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Assert - Event should be written with the configured sliding expiration Assert.Contains(mockCache.SetCalls, call => call.Key.Contains("event:") && call.Options.SlidingExpiration == TimeSpan.FromMinutes(30)); } [Fact] public async Task WriteEventAsync_UsesConfiguredAbsoluteExpiration() { // Arrange var mockCache = new TestDistributedCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { EventAbsoluteExpiration = TimeSpan.FromHours(6) }; var store = new DistributedCacheEventStreamStore(mockCache, customOptions); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); mockCache.SetCalls.Clear(); // Act await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Assert - Event should be written with the configured absolute expiration (relative to now) var eventCall = mockCache.SetCalls.FirstOrDefault(call => call.Key.Contains("event:")); Assert.NotNull(eventCall.Key); Assert.NotNull(eventCall.Options.AbsoluteExpirationRelativeToNow); Assert.Equal(TimeSpan.FromHours(6), eventCall.Options.AbsoluteExpirationRelativeToNow); } [Fact] public async Task WriteEventAsync_UsesConfiguredMetadataExpiration() { // Arrange - Metadata is written when events are written var mockCache = new TestDistributedCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { MetadataSlidingExpiration = TimeSpan.FromMinutes(45), MetadataAbsoluteExpiration = TimeSpan.FromHours(12) }; var store = new DistributedCacheEventStreamStore(mockCache, customOptions); var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); // Act - Write an event, which also updates metadata await writer.WriteEventAsync(new SseItem(null), CancellationToken); // Assert var metadataCall = mockCache.SetCalls.FirstOrDefault(call => call.Key.Contains("meta:")); Assert.NotNull(metadataCall.Key); Assert.Equal(TimeSpan.FromMinutes(45), metadataCall.Options.SlidingExpiration); Assert.Equal(TimeSpan.FromHours(12), metadataCall.Options.AbsoluteExpirationRelativeToNow); } [Fact] public void DefaultOptions_HaveReasonableDefaults() { // Arrange & Act var options = new DistributedCacheEventStreamStoreOptions(); // Assert - Check that defaults are set reasonably Assert.True(options.StreamReaderPollingInterval >= TimeSpan.FromMilliseconds(50), "Polling interval should be at least 50ms"); Assert.True(options.EventSlidingExpiration > TimeSpan.Zero, "Event sliding expiration should be positive"); Assert.True(options.EventAbsoluteExpiration > TimeSpan.Zero, "Event absolute expiration should be positive"); Assert.True(options.MetadataSlidingExpiration > TimeSpan.Zero, "Metadata sliding expiration should be positive"); Assert.True(options.MetadataAbsoluteExpiration > TimeSpan.Zero, "Metadata absolute expiration should be positive"); } [Fact] public async Task ReadEventsAsync_ThrowsMcpException_WhenMetadataExpires() { // Arrange - Use a cache that allows us to simulate metadata expiration var trackingCache = new TestDistributedCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(10) // Fast polling to detect the bug quickly }; var store = new DistributedCacheEventStreamStore(trackingCache, customOptions); // Create a stream and write an event var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Streaming // Non-polling mode to trigger the waiting loop }, CancellationToken); var item = new SseItem(new JsonRpcNotification { Method = "test" }); var writtenItem = await writer.WriteEventAsync(item, CancellationToken); // Get a reader starting after the first event (so it will wait for more events) var reader = await store.GetStreamReaderAsync(writtenItem.EventId!, CancellationToken); Assert.NotNull(reader); // Now simulate metadata expiration trackingCache.ExpireMetadata(); // Act & Assert - Reader should throw McpException when metadata expires var exception = await Assert.ThrowsAsync(async () => { await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { // Should not yield any events before throwing } }); Assert.Contains("session-1", exception.Message); Assert.Contains("stream-1", exception.Message); Assert.Contains("metadata", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ReadEventsAsync_ThrowsMcpException_WhenEventExpires() { // Arrange - Use a cache that allows us to simulate event expiration var trackingCache = new TestDistributedCache(); var store = new DistributedCacheEventStreamStore(trackingCache); // Create a stream and write multiple events var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var event1 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method1" }), CancellationToken); var event2 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method2" }), CancellationToken); var event3 = await writer.WriteEventAsync(new SseItem(new JsonRpcNotification { Method = "method3" }), CancellationToken); // Create a reader starting from before the first event var startEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); var reader = await store.GetStreamReaderAsync(startEventId, CancellationToken); Assert.NotNull(reader); // Simulate event2 expiring from the cache trackingCache.ExpireEvent(event2.EventId!); // Act & Assert - Reader should throw McpException when an event is missing var exception = await Assert.ThrowsAsync(async () => { var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } }); Assert.Contains(event2.EventId!, exception.Message); Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ReadEventsAsync_DoesNotReadMetadata_InPollingMode() { // Arrange - Use a tracking cache to count metadata reads var trackingCache = new TestDistributedCache(); var customOptions = new DistributedCacheEventStreamStoreOptions { StreamReaderPollingInterval = TimeSpan.FromMilliseconds(10) }; var store = new DistributedCacheEventStreamStore(trackingCache, customOptions); // Create a stream in POLLING mode - this allows the reader to exit after reading available events var writer = await store.CreateStreamAsync(new SseEventStreamOptions { SessionId = "session-1", StreamId = "stream-1", Mode = SseEventStreamMode.Polling }, CancellationToken); var item1 = new SseItem(new JsonRpcNotification { Method = "test1" }); var item2 = new SseItem(new JsonRpcNotification { Method = "test2" }); await writer.WriteEventAsync(item1, CancellationToken); await writer.WriteEventAsync(item2, CancellationToken); // Get a reader starting before all events (use a fake event ID at sequence 0) var zeroSequenceEventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 0); var reader = await store.GetStreamReaderAsync(zeroSequenceEventId, CancellationToken); Assert.NotNull(reader); // GetStreamReaderAsync should have read metadata exactly once Assert.Equal(1, trackingCache.MetadataReadCount); // Act - Read all events var events = new List>(); await foreach (var evt in reader.ReadEventsAsync(CancellationToken)) { events.Add(evt); } // Assert - In polling mode, the reader should: // 1. Use initial metadata from GetStreamReaderAsync (no additional read needed) // 2. Read all available events (2 events) // 3. Exit immediately because mode is Polling // // Metadata read count should remain at 1 (only the initial read from GetStreamReaderAsync) Assert.Equal(2, events.Count); Assert.Equal(1, trackingCache.MetadataReadCount); } [Fact] public void EventIdFormatter_Format_CreatesValidEventId() { // Act var eventId = DistributedCacheEventIdFormatter.Format("session-1", "stream-1", 42); // Assert Assert.NotNull(eventId); Assert.NotEmpty(eventId); Assert.Contains(":", eventId); // Should contain separators } [Fact] public void EventIdFormatter_TryParse_RoundTripsSuccessfully() { // Arrange var originalSessionId = "my-session-id"; var originalStreamId = "my-stream-id"; var originalSequence = 12345L; // Act var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence); var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence); // Assert Assert.True(parsed); Assert.Equal(originalSessionId, sessionId); Assert.Equal(originalStreamId, streamId); Assert.Equal(originalSequence, sequence); } [Fact] public void EventIdFormatter_TryParse_HandlesEmptySessionAndStreamIds() { // Arrange var originalSessionId = ""; var originalStreamId = ""; var originalSequence = 42L; // Act var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence); var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence); // Assert Assert.True(parsed); Assert.Equal(originalSessionId, sessionId); Assert.Equal(originalStreamId, streamId); Assert.Equal(originalSequence, sequence); } [Fact] public void EventIdFormatter_TryParse_HandlesSpecialCharactersInSessionId() { // Arrange - Session IDs can contain any visible ASCII character per MCP spec var originalSessionId = "session:with:colons:and|pipes"; var originalStreamId = "stream-1"; var originalSequence = 1L; // Act var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence); var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence); // Assert Assert.True(parsed); Assert.Equal(originalSessionId, sessionId); Assert.Equal(originalStreamId, streamId); Assert.Equal(originalSequence, sequence); } [Fact] public void EventIdFormatter_TryParse_HandlesSpecialCharactersInStreamId() { // Arrange var originalSessionId = "session-1"; var originalStreamId = "stream:with:colons:and|special!chars@#$%"; var originalSequence = 1L; // Act var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence); var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence); // Assert Assert.True(parsed); Assert.Equal(originalSessionId, sessionId); Assert.Equal(originalStreamId, streamId); Assert.Equal(originalSequence, sequence); } [Fact] public void EventIdFormatter_TryParse_HandlesUnicodeCharacters() { // Arrange var originalSessionId = "session-日本語-émojis-🎉"; var originalStreamId = "stream-中文-العربية"; var originalSequence = 999L; // Act var eventId = DistributedCacheEventIdFormatter.Format(originalSessionId, originalStreamId, originalSequence); var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out var sessionId, out var streamId, out var sequence); // Assert Assert.True(parsed); Assert.Equal(originalSessionId, sessionId); Assert.Equal(originalStreamId, streamId); Assert.Equal(originalSequence, sequence); } [Fact] public void EventIdFormatter_TryParse_HandlesZeroSequence() { // Act var eventId = DistributedCacheEventIdFormatter.Format("session", "stream", 0); var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out _, out _, out var sequence); // Assert Assert.True(parsed); Assert.Equal(0, sequence); } [Fact] public void EventIdFormatter_TryParse_HandlesLargeSequence() { // Act var eventId = DistributedCacheEventIdFormatter.Format("session", "stream", long.MaxValue); var parsed = DistributedCacheEventIdFormatter.TryParse(eventId, out _, out _, out var sequence); // Assert Assert.True(parsed); Assert.Equal(long.MaxValue, sequence); } [Fact] public void EventIdFormatter_TryParse_ReturnsFalse_ForEmptyString() { // Act var parsed = DistributedCacheEventIdFormatter.TryParse("", out var sessionId, out var streamId, out var sequence); // Assert Assert.False(parsed); Assert.Equal(string.Empty, sessionId); Assert.Equal(string.Empty, streamId); Assert.Equal(0, sequence); } [Fact] public void EventIdFormatter_TryParse_ReturnsFalse_ForInvalidFormat() { // Act & Assert - Various invalid formats Assert.False(DistributedCacheEventIdFormatter.TryParse("no-separators", out _, out _, out _)); Assert.False(DistributedCacheEventIdFormatter.TryParse("only:one", out _, out _, out _)); Assert.False(DistributedCacheEventIdFormatter.TryParse("too:many:parts:here", out _, out _, out _)); } [Fact] public void EventIdFormatter_TryParse_ReturnsFalse_ForInvalidBase64() { // Act - Invalid base64 in first part var parsed = DistributedCacheEventIdFormatter.TryParse("!!!invalid!!!:c3RyZWFt:1", out _, out _, out _); // Assert Assert.False(parsed); } [Fact] public void EventIdFormatter_TryParse_ReturnsFalse_ForNonNumericSequence() { // Arrange - Valid base64 but non-numeric sequence var sessionBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("session")); var streamBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("stream")); var invalidEventId = $"{sessionBase64}:{streamBase64}:not-a-number"; // Act var parsed = DistributedCacheEventIdFormatter.TryParse(invalidEventId, out _, out _, out _); // Assert Assert.False(parsed); } /// /// A distributed cache that tracks all operations for verification in tests. /// Supports tracking Set calls, counting metadata reads, and simulating metadata/event expiration. /// private sealed class TestDistributedCache : IDistributedCache { private readonly MemoryDistributedCache _innerCache = new(Options.Create(new MemoryDistributedCacheOptions())); private int _metadataReadCount; private bool _metadataExpired; private readonly HashSet _expiredEventIds = []; public List<(string Key, DistributedCacheEntryOptions Options)> SetCalls { get; } = []; public int MetadataReadCount => _metadataReadCount; public void ExpireMetadata() => _metadataExpired = true; public void ExpireEvent(string eventId) => _expiredEventIds.Add(eventId); public byte[]? Get(string key) { if (key.Contains("meta:")) { Interlocked.Increment(ref _metadataReadCount); if (_metadataExpired) { return null; } } if (IsExpiredEvent(key)) { return null; } return _innerCache.Get(key); } public Task GetAsync(string key, CancellationToken token = default) { if (key.Contains("meta:")) { Interlocked.Increment(ref _metadataReadCount); if (_metadataExpired) { return Task.FromResult(null); } } if (IsExpiredEvent(key)) { return Task.FromResult(null); } return _innerCache.GetAsync(key, token); } private bool IsExpiredEvent(string key) { // Cache key format is "mcp:sse:event:{eventId}" foreach (var expiredEventId in _expiredEventIds) { if (key.EndsWith(expiredEventId)) { return true; } } return false; } public void Refresh(string key) => _innerCache.Refresh(key); public Task RefreshAsync(string key, CancellationToken token = default) => _innerCache.RefreshAsync(key, token); public void Remove(string key) => _innerCache.Remove(key); public Task RemoveAsync(string key, CancellationToken token = default) => _innerCache.RemoveAsync(key, token); public void Set(string key, byte[] value, DistributedCacheEntryOptions options) { SetCalls.Add((key, options)); _innerCache.Set(key, value, options); } public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) { SetCalls.Add((key, options)); return _innerCache.SetAsync(key, value, options, token); } } }