File: Mcp\Docs\DocsFetcherTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.Net;
using Aspire.Cli.Mcp.Docs;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Mcp.Docs;
 
public class DocsFetcherTests
{
    [Fact]
    public async Task FetchDocsAsync_SuccessfulRequest_ReturnsContent()
    {
        var expectedContent = """
            # Redis Integration
            > Connect to Redis.
 
            Content here.
            """;
 
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(expectedContent)
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Equal(expectedContent, content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_StoresETag_WhenProvided()
    {
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent("# Content")
        };
        response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"abc123\"");
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        await fetcher.FetchDocsAsync();
 
        var storedETag = await cache.GetETagAsync("https://aspire.dev/llms-small.txt");
        Assert.Equal("\"abc123\"", storedETag);
    }
 
    [Fact]
    public async Task FetchDocsAsync_CachesContent()
    {
        var content = "# Cached Content";
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(content)
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        await fetcher.FetchDocsAsync();
 
        var cached = await cache.GetAsync("https://aspire.dev/llms-small.txt");
        Assert.Equal(content, cached);
    }
 
    [Fact]
    public async Task FetchDocsAsync_WithCachedETag_SendsIfNoneMatchHeader()
    {
        var cache = new MockDocsCache();
        await cache.SetETagAsync("https://aspire.dev/llms-small.txt", "\"cached-etag\"");
        await cache.SetAsync("https://aspire.dev/llms-small.txt", "# Cached");
 
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.NotModified
        };
 
        using var handler = new MockHttpMessageHandler(response, request =>
        {
            Assert.Contains("\"cached-etag\"", request.Headers.IfNoneMatch.ToString());
        });
 
        using var httpClient = new HttpClient(handler);
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        await fetcher.FetchDocsAsync();
 
        Assert.True(handler.RequestValidated);
    }
 
    [Fact]
    public async Task FetchDocsAsync_NotModifiedResponse_ReturnsCachedContent()
    {
        var cachedContent = "# Cached Content";
        var cache = new MockDocsCache();
        await cache.SetETagAsync("https://aspire.dev/llms-small.txt", "\"etag\"");
        await cache.SetAsync("https://aspire.dev/llms-small.txt", cachedContent);
 
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.NotModified
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Equal(cachedContent, content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_NotModifiedButCacheEmpty_RetriesWithoutETag()
    {
        var freshContent = "# Fresh Content";
        var cache = new MockDocsCache();
        await cache.SetETagAsync("https://aspire.dev/llms-small.txt", "\"etag\"");
        // Cache content is empty - simulating cache cleared but ETag remains
 
        var callCount = 0;
        using var handler = new MockHttpMessageHandler(_ =>
        {
            callCount++;
            if (callCount == 1)
            {
                // First call returns NotModified
                return new HttpResponseMessage { StatusCode = HttpStatusCode.NotModified };
            }
            // Retry returns fresh content
            var response = new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.OK,
                Content = new StringContent(freshContent)
            };
            response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"new-etag\"");
            return response;
        });
 
        using var httpClient = new HttpClient(handler);
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Equal(freshContent, content);
        Assert.Equal(2, callCount);
    }
 
    [Fact]
    public async Task FetchDocsAsync_FailedRequest_ReturnsNull()
    {
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.InternalServerError
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Null(content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_NetworkError_ReturnsNull()
    {
        using var handler = new MockHttpMessageHandler(new HttpRequestException("Network error"));
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Null(content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_Cancellation_ReturnsNull()
    {
        // Use a handler that properly checks the cancellation token
        using var handler = new CancellationCheckingHandler();
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        using var cts = new CancellationTokenSource();
        cts.Cancel();
 
        // The FetchDocsAsync swallows exceptions and returns null when there's no cached content
        var result = await fetcher.FetchDocsAsync(cts.Token);
        Assert.Null(result);
    }
 
    [Fact]
    public async Task FetchDocsAsync_EmptyResponse_ReturnsEmptyString()
    {
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent("")
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Equal("", content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_WhitespaceOnlyResponse_ReturnsWhitespace()
    {
        var whitespace = "   \n\t\n   ";
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(whitespace)
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Equal(whitespace, content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_NullContent_ReturnsEmptyString()
    {
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = null
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        // When Content is set to null, .NET replaces it with EmptyContent, so ReadAsStringAsync returns empty string
        Assert.Equal("", content);
    }
 
    [Theory]
    [InlineData(HttpStatusCode.NotFound)]
    [InlineData(HttpStatusCode.BadRequest)]
    [InlineData(HttpStatusCode.ServiceUnavailable)]
    [InlineData(HttpStatusCode.GatewayTimeout)]
    public async Task FetchDocsAsync_ErrorStatusCode_ReturnsNull(HttpStatusCode statusCode)
    {
        using var response = new HttpResponseMessage
        {
            StatusCode = statusCode
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Null(content);
    }
 
    public static TheoryData<Exception> ExceptionData => new()
    {
        new TaskCanceledException("Request timed out"),
        new OperationCanceledException("Operation was cancelled"),
        new InvalidOperationException("Invalid state")
    };
 
    [Theory]
    [MemberData(nameof(ExceptionData))]
    public async Task FetchDocsAsync_Exception_ReturnsNull(Exception exception)
    {
        using var handler = new MockHttpMessageHandler(exception);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Null(content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_CacheReturnsNull_FetchesFromServer()
    {
        var serverContent = "# Server Content";
        using var response = new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(serverContent)
        };
 
        using var handler = new MockHttpMessageHandler(response);
        using var httpClient = new HttpClient(handler);
        var cache = new MockDocsCache();
        // Cache is empty, no ETag
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Equal(serverContent, content);
    }
 
    [Fact]
    public async Task FetchDocsAsync_WithETagButNoCachedContent_ClearsETagAndRetries()
    {
        var serverContent = "# Fresh Content";
        var cache = new MockDocsCache();
        await cache.SetETagAsync("https://aspire.dev/llms-small.txt", "\"old-etag\"");
        // Note: no cached content set
 
        var callCount = 0;
        using var handler = new MockHttpMessageHandler(_ =>
        {
            callCount++;
            if (callCount == 1)
            {
                return new HttpResponseMessage { StatusCode = HttpStatusCode.NotModified };
            }
            return new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.OK,
                Content = new StringContent(serverContent)
            };
        });
 
        using var httpClient = new HttpClient(handler);
        var fetcher = new DocsFetcher(httpClient, cache, NullLogger<DocsFetcher>.Instance);
 
        var content = await fetcher.FetchDocsAsync();
 
        Assert.Equal(serverContent, content);
        Assert.Equal(2, callCount);
    }
 
    private sealed class CancellationCheckingHandler : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("# Content")
            });
        }
    }
 
    private sealed class MockHttpMessageHandler : HttpMessageHandler
    {
        private readonly Func<HttpRequestMessage, HttpResponseMessage>? _responseFactory;
        private readonly HttpResponseMessage? _response;
        private readonly Exception? _exception;
        private readonly Action<HttpRequestMessage>? _requestValidator;
 
        public bool RequestValidated { get; private set; }
 
        public MockHttpMessageHandler(HttpResponseMessage response, Action<HttpRequestMessage>? requestValidator = null)
        {
            _response = response;
            _requestValidator = requestValidator;
        }
 
        public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
        {
            _responseFactory = responseFactory;
        }
 
        public MockHttpMessageHandler(Exception exception)
        {
            _exception = exception;
        }
 
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            if (_exception is not null)
            {
                throw _exception;
            }
 
            if (_requestValidator is not null)
            {
                _requestValidator(request);
                RequestValidated = true;
            }
 
            if (_responseFactory is not null)
            {
                return Task.FromResult(_responseFactory(request));
            }
 
            return Task.FromResult(_response!);
        }
    }
 
    private sealed class MockDocsCache : IDocsCache
    {
        private readonly Dictionary<string, string> _content = [];
        private readonly Dictionary<string, string> _etags = [];
        private LlmsDocument[]? _index;
 
        public Task<string?> GetAsync(string key, CancellationToken cancellationToken = default)
        {
            _content.TryGetValue(key, out var value);
            return Task.FromResult(value);
        }
 
        public Task SetAsync(string key, string content, CancellationToken cancellationToken = default)
        {
            _content[key] = content;
            return Task.CompletedTask;
        }
 
        public Task<string?> GetETagAsync(string url, CancellationToken cancellationToken = default)
        {
            _etags.TryGetValue(url, out var value);
            return Task.FromResult(value);
        }
 
        public Task SetETagAsync(string url, string? etag, CancellationToken cancellationToken = default)
        {
            if (etag is null)
            {
                _etags.Remove(url);
            }
            else
            {
                _etags[url] = etag;
            }
            return Task.CompletedTask;
        }
 
        public Task<LlmsDocument[]?> GetIndexAsync(CancellationToken cancellationToken = default)
        {
            return Task.FromResult(_index);
        }
 
        public Task SetIndexAsync(LlmsDocument[] documents, CancellationToken cancellationToken = default)
        {
            _index = documents;
            return Task.CompletedTask;
        }
 
        public Task InvalidateAsync(string key, CancellationToken cancellationToken = default)
        {
            _content.Remove(key);
            return Task.CompletedTask;
        }
    }
}