File: Mcp\Docs\DocsSearchServiceTests.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 Aspire.Cli.Mcp.Docs;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Mcp.Docs;
 
public class DocsSearchServiceTests
{
    private static DocsSearchService CreateService(IDocsIndexService indexService)
    {
        return new DocsSearchService(indexService, NullLogger<DocsSearchService>.Instance);
    }
 
    private static DocsIndexService CreateIndexService(IDocsFetcher? fetcher = null, IDocsCache? cache = null)
    {
        return new DocsIndexService(
            fetcher ?? new MockDocsFetcher(null),
            cache ?? new NullDocsCache(),
            NullLogger<DocsIndexService>.Instance);
    }
 
    [Fact]
    public async Task SearchAsync_ReturnsFormattedResponse()
    {
        var content = """
            # Redis Integration
            > Connect to Redis for caching.
 
            Redis content with details.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
 
        Assert.NotNull(response);
        Assert.Equal("Redis", response.Query);
        Assert.NotEmpty(response.Results);
    }
 
    [Fact]
    public async Task SearchAsync_WithNoResults_ReturnsEmptyResults()
    {
        var content = """
            # PostgreSQL Integration
            > Connect to PostgreSQL databases.
 
            PostgreSQL content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("nonexistent-term-xyz");
 
        Assert.NotNull(response);
        Assert.Empty(response.Results);
    }
 
    [Fact]
    public async Task FormatAsMarkdown_WithResults_FormatsCorrectly()
    {
        var content = """
            # Redis Integration
            > Connect to Redis for caching.
 
            Redis content with details.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
        Assert.NotNull(response);
 
        var markdown = response.FormatAsMarkdown("Test Results");
 
        Assert.Contains("# Test Results", markdown);
        Assert.Contains("## Redis Integration", markdown);
        Assert.Contains("**Slug:**", markdown);
    }
 
    [Fact]
    public async Task FormatAsMarkdown_WithScores_IncludesScores()
    {
        var content = """
            # Redis Integration
            > Connect to Redis for caching.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
        Assert.NotNull(response);
 
        var markdown = response.FormatAsMarkdown(showScores: true);
 
        Assert.Contains("Score:", markdown);
    }
 
    [Fact]
    public async Task FormatAsMarkdown_NoResults_ReturnsHelpfulMessage()
    {
        var content = """
            # PostgreSQL Integration
            > Connect to PostgreSQL databases.
 
            PostgreSQL content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("xyz-not-found");
        Assert.NotNull(response);
 
        var markdown = response.FormatAsMarkdown();
 
        Assert.Contains("No results found", markdown);
        Assert.Contains("xyz-not-found", markdown);
    }
 
    [Fact]
    public async Task SearchAsync_RespectsTopKLimit()
    {
        var content = """
            # Redis Doc 1
            > Redis documentation part 1.
 
            Redis content here.
 
            # Redis Doc 2
            > Redis documentation part 2.
 
            More Redis content.
 
            # Redis Doc 3
            > Redis documentation part 3.
 
            Redis again here.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis", topK: 2);
 
        Assert.NotNull(response);
        Assert.Equal(2, response.Results.Count);
    }
 
    [Fact]
    public async Task SearchAsync_IncludesMatchedSection()
    {
        var content = """
            # Getting Started
            > Quick start guide.
 
            ## Redis Configuration
            Configure Redis connection strings.
 
            ## PostgreSQL Configuration
            Configure PostgreSQL.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
        Assert.NotNull(response);
        Assert.NotEmpty(response.Results);
 
        var result = response.Results[0];
        Assert.NotNull(result.Section);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task SearchAsync_WithInvalidQuery_ReturnsResponseWithEmptyResults(string? query)
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync(query!);
 
        Assert.NotNull(response);
        Assert.Empty(response.Results);
    }
 
    [Fact]
    public async Task SearchAsync_WhenNoDocsAvailable_ReturnsResponseWithEmptyResults()
    {
        var fetcher = new MockDocsFetcher(null);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
 
        Assert.NotNull(response);
        Assert.Empty(response.Results);
    }
 
    [Fact]
    public async Task SearchAsync_WhenDocsEmpty_ReturnsResponseWithEmptyResults()
    {
        var fetcher = new MockDocsFetcher("");
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
 
        Assert.NotNull(response);
        Assert.Empty(response.Results);
    }
 
    [Fact]
    public async Task FormatAsMarkdown_WithNullTitle_UsesDefaultTitle()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
        Assert.NotNull(response);
 
        var markdown = response.FormatAsMarkdown(null);
 
        Assert.Contains("Documentation for:", markdown);
        Assert.Contains("Redis", markdown);
    }
 
    [Fact]
    public async Task FormatAsMarkdown_WithEmptyTitle_UsesEmptyTitle()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis");
        Assert.NotNull(response);
 
        var markdown = response.FormatAsMarkdown("");
 
        // Verify the content contains the document title and slug
        Assert.Contains("## Redis Integration", markdown);
        Assert.Contains("**Slug:**", markdown);
        Assert.Contains("Redis", markdown);
    }
 
    [Fact]
    public async Task SearchAsync_WithZeroTopK_ReturnsEmptyResults()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis", topK: 0);
 
        Assert.NotNull(response);
        Assert.Empty(response.Results);
    }
 
    [Fact]
    public async Task SearchAsync_WithNegativeTopK_ReturnsEmptyResults()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis", topK: -5);
 
        Assert.NotNull(response);
        Assert.Empty(response.Results);
    }
 
    [Fact]
    public async Task SearchAsync_WithLargeTopK_ReturnsAllAvailableResults()
    {
        var content = """
            # Redis Doc 1
            > Redis content 1.
 
            Redis info.
 
            # Redis Doc 2
            > Redis content 2.
 
            More Redis.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis", topK: 1000);
 
        Assert.NotNull(response);
        Assert.Equal(2, response.Results.Count);
    }
 
    [Fact]
    public async Task SearchAsync_WhenIndexerThrows_PropagatesException()
    {
        var fetcher = new ThrowingDocsFetcher(new InvalidOperationException("Index failed"));
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        await Assert.ThrowsAsync<InvalidOperationException>(() => searchService.SearchAsync("Redis"));
    }
 
    [Fact]
    public async Task SearchAsync_WithSpecialCharactersInQuery_HandlesGracefully()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Redis!@#$%^&*()");
 
        Assert.NotNull(response);
        // Should not throw, may or may not have results depending on tokenization
    }
 
    [Fact]
    public async Task SearchAsync_WithUnicodeCharacters_HandlesGracefully()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = new MockDocsFetcher(content);
        var indexService = CreateIndexService(fetcher);
        var searchService = CreateService(indexService);
 
        var response = await searchService.SearchAsync("Rédis 日本語 🚀");
 
        Assert.NotNull(response);
        // Should not throw
    }
 
    private sealed class MockDocsFetcher(string? content) : IDocsFetcher
    {
        public Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default)
        {
            return Task.FromResult(content);
        }
    }
 
    private sealed class ThrowingDocsFetcher(Exception exception) : IDocsFetcher
    {
        public Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default)
        {
            throw exception;
        }
    }
 
    private sealed class NullDocsCache : IDocsCache
    {
        public Task<string?> GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult<string?>(null);
        public Task SetAsync(string key, string content, CancellationToken cancellationToken = default) => Task.CompletedTask;
        public Task<string?> GetETagAsync(string url, CancellationToken cancellationToken = default) => Task.FromResult<string?>(null);
        public Task SetETagAsync(string url, string? etag, CancellationToken cancellationToken = default) => Task.CompletedTask;
        public Task<LlmsDocument[]?> GetIndexAsync(CancellationToken cancellationToken = default) => Task.FromResult<LlmsDocument[]?>(null);
        public Task SetIndexAsync(LlmsDocument[] documents, CancellationToken cancellationToken = default) => Task.CompletedTask;
        public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) => Task.CompletedTask;
    }
}