File: ServerSentEventsResultTests.cs
Web Access
Project: src\src\Http\Http.Results\test\Microsoft.AspNetCore.Http.Results.Tests.csproj (Microsoft.AspNetCore.Http.Results.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Pipelines;
using System.Net.ServerSentEvents;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
 
namespace Microsoft.AspNetCore.Http.HttpResults;
 
public class ServerSentEventsResultTests
{
    [Fact]
    public async Task ExecuteAsync_SetsContentTypeAndHeaders()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var events = AsyncEnumerable.Empty<string>();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        Assert.Equal("text/event-stream", httpContext.Response.ContentType);
        Assert.Equal("no-cache,no-store", httpContext.Response.Headers.CacheControl);
        Assert.Equal("no-cache", httpContext.Response.Headers.Pragma);
        Assert.Equal("identity", httpContext.Response.Headers.ContentEncoding);
    }
 
    [Fact]
    public async Task ExecuteAsync_WritesStringEventsToResponse()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var events = new[] { "event1\"with\"quotes", "event2" }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains("data: event1\"with\"quotes\n\n", responseBody);
        Assert.Contains("data: event2\n\n", responseBody);
 
        // Verify strings are not JSON serialized
        Assert.DoesNotContain("data: \"event1", responseBody);
        Assert.DoesNotContain("data: \"event2", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_WritesStringsEventsWithEventType()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var events = new[] { "event1", "event2" }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events, "test-event");
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains("event: test-event\ndata: event1\n\n", responseBody);
        Assert.Contains("event: test-event\ndata: event2\n\n", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithSseItems_WritesStringEventsDirectly()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var events = new[] { new SseItem<string>("event1", "custom-event") }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains("event: custom-event\n", responseBody);
        Assert.Contains("data: event1\n\n", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_ThrowsArgumentNullException_WhenHttpContextIsNull()
    {
        // Arrange
        var events = AsyncEnumerable.Empty<string>();
        var result = TypedResults.ServerSentEvents(events);
        HttpContext httpContext = null;
 
        // Act & Assert
        await Assert.ThrowsAsync<ArgumentNullException>("httpContext", () => result.ExecuteAsync(httpContext));
    }
 
    [Fact]
    public async Task ExecuteAsync_HandlesNullData()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var events = new string[] { null }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains("data: \n\n", responseBody);
    }
 
    [Fact]
    public void PopulateMetadata_AddsResponseTypeMetadata()
    {
        // Arrange
        ServerSentEventsResult<string> MyApi() { throw new NotImplementedException(); }
        var builder = new RouteEndpointBuilder(requestDelegate: null, RoutePatternFactory.Parse("/"), order: 0);
 
        // Act
        PopulateMetadata<ServerSentEventsResult<string>>(((Delegate)MyApi).GetMethodInfo(), builder);
 
        // Assert
        var producesResponseTypeMetadata = builder.Metadata.OfType<ProducesResponseTypeMetadata>().Last();
        Assert.Equal(StatusCodes.Status200OK, producesResponseTypeMetadata.StatusCode);
        Assert.Equal(typeof(SseItem<string>), producesResponseTypeMetadata.Type);
        Assert.Collection(producesResponseTypeMetadata.ContentTypes,
            contentType => Assert.Equal("text/event-stream", contentType));
    }
 
    [Fact]
    public async Task ExecuteAsync_WithObjectData_SerializesAsJson()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var testObject = new TestObject { Name = "Test", Value = 42 };
        var events = new[] { testObject }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains(@"data: {""name"":""Test"",""value"":42}", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithSsItems_SerializesDataAsJson()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var testObject = new TestObject { Name = "Test", Value = 42 };
        var events = new[] { new SseItem<TestObject>(testObject) }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains(@"data: {""name"":""Test"",""value"":42}", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithCustomJsonOptions_UsesConfiguredOptions()
    {
        // Arrange
        var services = new ServiceCollection();
        services.AddLogging();
        services.Configure<JsonOptions>(options =>
        {
            options.SerializerOptions.PropertyNamingPolicy = null; // Use PascalCase
        });
        var httpContext = new DefaultHttpContext
        {
            Response = { Body = new MemoryStream() },
            RequestServices = services.BuildServiceProvider()
        };
 
        var testObject = new TestObject { Name = "Test", Value = 42 };
        var events = new[] { testObject }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains(@"data: {""Name"":""Test"",""Value"":42}", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithPolymorphicType_SerializesCorrectly()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var baseClass = new DerivedTestObject { Name = "Test", Value = 42, Extra = "Additional" };
        var events = new TestObject[] { baseClass }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains(@"data: {""extra"":""Additional"",""name"":""Test"",""value"":42}", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_ObservesCancellationViaRequestAborted()
    {
        // Arrange
        var cts = new CancellationTokenSource();
        var httpContext = GetHttpContext();
        httpContext.RequestAborted = cts.Token;
        var firstEventReceived = new TaskCompletionSource();
        var secondEventAttempted = new TaskCompletionSource();
        var cancellationObserved = new TaskCompletionSource();
 
        var events = GetEvents(cts.Token);
        var result = TypedResults.ServerSentEvents(events);
 
        // Act & Assert
        var executeTask = result.ExecuteAsync(httpContext);
 
        // Wait for first event to be processed then cancel the request and wait
        // to observe the cancellation
        await firstEventReceived.Task;
        cts.Cancel();
        await secondEventAttempted.Task;
 
        // Verify the execution was cancelled and only the first event was written
        await Assert.ThrowsAsync<TaskCanceledException>(() => executeTask);
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains("data: event1\n\n", responseBody);
        Assert.DoesNotContain("data: event2\n\n", responseBody);
 
        async IAsyncEnumerable<string> GetEvents([EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            try
            {
                yield return "event1";
                firstEventReceived.SetResult();
                cancellationToken.Register(cancellationObserved.SetResult);
                await cancellationObserved.Task;
                yield return "event2";
            }
            finally
            {
                secondEventAttempted.SetResult();
            }
        }
    }
 
    [Fact]
    public async Task ExecuteAsync_DisablesBuffering()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var events = AsyncEnumerable.Empty<string>();
        var result = TypedResults.ServerSentEvents(events);
        var bufferingDisabled = false;
 
        var mockBufferingFeature = new MockHttpResponseBodyFeature(
            onDisableBuffering: () => bufferingDisabled = true);
 
        httpContext.Features.Set<IHttpResponseBodyFeature>(mockBufferingFeature);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        Assert.True(bufferingDisabled);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithByteArrayData_WritesDataDirectly()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var bytes = "event1"u8.ToArray();
        var events = new[] { new SseItem<byte[]>(bytes) }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains("data: event1\n\n", responseBody);
 
        // Assert that string is not JSON serialized
        Assert.DoesNotContain("data: \"event1", responseBody);
    }
 
    [Fact]
    public async Task ExecuteAsync_WithByteArrayData_HandlesNullData()
    {
        // Arrange
        var httpContext = GetHttpContext();
        var events = new[] { new SseItem<byte[]>(null) }.ToAsyncEnumerable();
        var result = TypedResults.ServerSentEvents(events);
 
        // Act
        await result.ExecuteAsync(httpContext);
 
        // Assert
        var responseBody = Encoding.UTF8.GetString(((MemoryStream)httpContext.Response.Body).ToArray());
        Assert.Contains("data: \n\n", responseBody);
    }
 
    private class MockHttpResponseBodyFeature(Action onDisableBuffering) : IHttpResponseBodyFeature
    {
        public Stream Stream => new MemoryStream();
        public PipeWriter Writer => throw new NotImplementedException();
        public Task CompleteAsync() => throw new NotImplementedException();
        public void DisableBuffering() => onDisableBuffering();
        public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default)
            => throw new NotImplementedException();
        public Task StartAsync(CancellationToken cancellationToken = default)
            => throw new NotImplementedException();
    }
 
    private static void PopulateMetadata<TResult>(MethodInfo method, EndpointBuilder builder)
        where TResult : IEndpointMetadataProvider => TResult.PopulateMetadata(method, builder);
 
    private static DefaultHttpContext GetHttpContext()
    {
        var httpContext = new DefaultHttpContext();
        httpContext.Response.Body = new MemoryStream();
        httpContext.RequestServices = CreateServices();
        return httpContext;
    }
 
    private static ServiceProvider CreateServices()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        return services.BuildServiceProvider();
    }
 
    private class TestObject
    {
        public string Name { get; set; }
        public int Value { get; set; }
    }
 
    private class DerivedTestObject : TestObject
    {
        public string Extra { get; set; }
    }
}