File: Mcp\Docs\DocsIndexServiceTests.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 DocsIndexServiceTests
{
    private static IDocsFetcher CreateMockFetcher(string? content)
    {
        return new MockDocsFetcher(content);
    }
 
    private static DocsIndexService CreateService(IDocsFetcher? fetcher = null, IDocsCache? cache = null)
    {
        return new DocsIndexService(
            fetcher ?? new MockDocsFetcher(null),
            cache ?? new NullDocsCache(),
            NullLogger<DocsIndexService>.Instance);
    }
 
    [Fact]
    public async Task ListDocumentsAsync_ReturnsAllDocuments()
    {
        var content = """
            # Redis Integration
            > Connect to Redis for caching.
 
            Redis content.
 
            # PostgreSQL Integration
            > Connect to PostgreSQL databases.
 
            PostgreSQL content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var docs = await service.ListDocumentsAsync();
 
        Assert.Equal(2, docs.Count);
        Assert.Contains(docs, d => d.Title == "Redis Integration");
        Assert.Contains(docs, d => d.Title == "PostgreSQL Integration");
    }
 
    [Fact]
    public async Task ListDocumentsAsync_WhenFetchFails_ReturnsEmptyList()
    {
        var fetcher = CreateMockFetcher(null);
        var service = CreateService(fetcher);
 
        var docs = await service.ListDocumentsAsync();
 
        Assert.Empty(docs);
    }
 
    [Fact]
    public async Task SearchAsync_FindsDocumentByTitle()
    {
        var content = """
            # Redis Integration
            > Connect to Redis for caching.
 
            Redis content.
 
            # PostgreSQL Integration
            > Connect to PostgreSQL databases.
 
            PostgreSQL content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis");
 
        Assert.NotEmpty(results);
        Assert.Equal("Redis Integration", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_FindsDocumentBySummary()
    {
        var content = """
            # Integration Guide
            > Learn how to connect Redis caching to your app.
 
            Some content here.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("caching");
 
        Assert.NotEmpty(results);
        Assert.Equal("Integration Guide", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_FindsDocumentBySectionHeading()
    {
        var content = """
            # Getting Started
            > Quick start guide.
 
            ## Configuration Options
            Configure the app using environment variables.
 
            ## Deployment Steps
            Deploy to Azure Container Apps.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Configuration");
 
        Assert.NotEmpty(results);
        Assert.Equal("Getting Started", results[0].Title);
        Assert.Equal("Configuration Options", results[0].MatchedSection);
    }
 
    [Fact]
    public async Task SearchAsync_TitleMatchScoresHigherThanBodyMatch()
    {
        var content = """
            # Redis Overview
            > Official Redis documentation.
 
            This document covers Redis basics and setup.
 
            # Database Overview
            > Learn about databases.
 
            PostgreSQL and MySQL are popular database options. Redis is sometimes mentioned.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis");
 
        Assert.NotEmpty(results);
        // Document with "Redis" in title should rank higher
        Assert.Equal("Redis Overview", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_FindsCodeIdentifiers()
    {
        var content = """
            # Redis Integration
            > Add Redis to your app.
 
            ## Usage
 
            ```csharp
            var redis = builder.AddRedis("cache");
            ```
 
            Call `AddRedis` to add a Redis resource.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("AddRedis");
 
        Assert.NotEmpty(results);
        Assert.Equal("Redis Integration", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_RespectsTopKLimit()
    {
        var content = """
            # Doc 1
            > Redis documentation.
 
            Redis content here.
 
            # Doc 2
            > More Redis info.
 
            Redis info here.
 
            # Doc 3
            > Yet more Redis.
 
            More Redis content.
 
            # Doc 4
            > Redis again.
 
            Redis again here.
 
            # Doc 5
            > Redis everywhere.
 
            Redis everywhere here.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis", topK: 3);
 
        Assert.Equal(3, results.Count);
    }
 
    [Fact]
    public async Task SearchAsync_WithEmptyQuery_ReturnsEmptyResults()
    {
        var content = """
            # Some Document
            Content here.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("");
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task SearchAsync_WithWhitespaceQuery_ReturnsEmptyResults()
    {
        var content = """
            # Some Document
            Content here.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("   ");
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task SearchAsync_MultiWordQuery_FindsAllTerms()
    {
        var content = """
            # Redis Caching Guide
            > How to use Redis for caching.
 
            Implement distributed caching with Redis.
 
            # Memory Caching
            > In-memory caching without Redis.
 
            Simple memory cache implementation.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis caching");
 
        Assert.NotEmpty(results);
        // Document with both terms should rank highest
        Assert.Equal("Redis Caching Guide", results[0].Title);
    }
 
    [Fact]
    public async Task GetDocumentAsync_BySlug_ReturnsDocument()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("redis-integration");
 
        Assert.NotNull(doc);
        Assert.Equal("Redis Integration", doc.Title);
        Assert.Equal("redis-integration", doc.Slug);
    }
 
    [Fact]
    public async Task GetDocumentAsync_CaseInsensitive()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("REDIS-INTEGRATION");
 
        Assert.NotNull(doc);
        Assert.Equal("Redis Integration", doc.Title);
    }
 
    [Fact]
    public async Task GetDocumentAsync_UnknownSlug_ReturnsNull()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("nonexistent-doc");
 
        Assert.Null(doc);
    }
 
    [Fact]
    public async Task GetDocumentAsync_WithSection_ReturnsOnlySection()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Main content.
 
            ## Installation
            Install via NuGet.
 
            ## Configuration
            Configure connection strings.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("redis-integration", "Installation");
 
        Assert.NotNull(doc);
        Assert.Contains("Install via NuGet", doc.Content);
        Assert.DoesNotContain("Configure connection strings", doc.Content);
    }
 
    [Fact]
    public async Task GetDocumentAsync_WithPartialSectionName_FindsSection()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            ## Getting Started with Redis
            Quick start content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("redis-integration", "Getting Started");
 
        Assert.NotNull(doc);
        Assert.Contains("Quick start content", doc.Content);
    }
 
    [Fact]
    public async Task GetDocumentAsync_ReturnsSectionsList()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            ## Installation
            Install content.
 
            ## Configuration
            Config content.
 
            ## Usage
            Usage content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("redis-integration");
 
        Assert.NotNull(doc);
        Assert.Equal(3, doc.Sections.Count);
        Assert.Contains("Installation", doc.Sections);
        Assert.Contains("Configuration", doc.Sections);
        Assert.Contains("Usage", doc.Sections);
    }
 
    [Fact]
    public async Task EnsureIndexedAsync_OnlyFetchesOnce()
    {
        var callCount = 0;
        var fetcher = new CountingDocsFetcher(() =>
        {
            callCount++;
            return "# Doc\nContent.";
        });
        var service = CreateService(fetcher);
 
        await service.EnsureIndexedAsync();
        await service.EnsureIndexedAsync();
        await service.EnsureIndexedAsync();
 
        Assert.Equal(1, callCount);
    }
 
    [Fact]
    public async Task SearchAsync_OrdersResultsByScore()
    {
        var content = """
            # Redis Quick Start
            > Get started with Redis in minutes.
 
            ## Installation
            Install Redis.
 
            # Advanced Redis Patterns
            > Deep dive into Redis patterns and best practices.
 
            ## Redis Pub/Sub
            Learn about Redis publish/subscribe.
 
            ## Redis Clustering
            Configure Redis clustering for high availability.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis");
 
        // All results should have scores in descending order
        for (var i = 1; i < results.Count; i++)
        {
            Assert.True(results[i - 1].Score >= results[i].Score,
                $"Results not in descending score order: {results[i - 1].Score} < {results[i].Score}");
        }
    }
 
    [Fact]
    public async Task SearchAsync_WithNullQuery_ReturnsEmptyResults()
    {
        var content = """
            # Some Document
            > Summary here.
 
            Content here.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync(null!);
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task GetDocumentAsync_WithNullSlug_ReturnsNull()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync(null!);
 
        Assert.Null(doc);
    }
 
    [Fact]
    public async Task GetDocumentAsync_WithEmptySlug_ReturnsNull()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("");
 
        Assert.Null(doc);
    }
 
    [Fact]
    public async Task GetDocumentAsync_WithWhitespaceSlug_ReturnsNull()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("   ");
 
        Assert.Null(doc);
    }
 
    [Fact]
    public async Task ListDocumentsAsync_WhenFetchReturnsEmpty_ReturnsEmptyList()
    {
        var fetcher = CreateMockFetcher("");
        var service = CreateService(fetcher);
 
        var docs = await service.ListDocumentsAsync();
 
        Assert.Empty(docs);
    }
 
    [Fact]
    public async Task ListDocumentsAsync_WhenFetchReturnsWhitespace_ReturnsEmptyList()
    {
        var fetcher = CreateMockFetcher("   \n\t\n   ");
        var service = CreateService(fetcher);
 
        var docs = await service.ListDocumentsAsync();
 
        Assert.Empty(docs);
    }
 
    [Fact]
    public async Task SearchAsync_WhenNoDocsIndexed_ReturnsEmptyResults()
    {
        var fetcher = CreateMockFetcher(null);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis");
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task GetDocumentAsync_WhenNoDocsIndexed_ReturnsNull()
    {
        var fetcher = CreateMockFetcher(null);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("any-slug");
 
        Assert.Null(doc);
    }
 
    [Fact]
    public async Task ListDocumentsAsync_WhenFetcherThrows_PropagatesException()
    {
        var fetcher = new ThrowingDocsFetcher(new InvalidOperationException("Fetch failed"));
        var service = CreateService(fetcher);
 
        await Assert.ThrowsAsync<InvalidOperationException>(() => service.ListDocumentsAsync().AsTask());
    }
 
    [Fact]
    public async Task SearchAsync_WhenFetcherThrows_PropagatesException()
    {
        var fetcher = new ThrowingDocsFetcher(new HttpRequestException("Network error"));
        var service = CreateService(fetcher);
 
        await Assert.ThrowsAsync<HttpRequestException>(() => service.SearchAsync("Redis").AsTask());
    }
 
    [Fact]
    public async Task GetDocumentAsync_WhenFetcherThrows_PropagatesException()
    {
        var fetcher = new ThrowingDocsFetcher(new TimeoutException("Request timed out"));
        var service = CreateService(fetcher);
 
        await Assert.ThrowsAsync<TimeoutException>(() => service.GetDocumentAsync("redis-integration").AsTask());
    }
 
    [Fact]
    public async Task EnsureIndexedAsync_WhenCancelled_ThrowsOperationCanceledException()
    {
        var fetcher = new DelayingDocsFetcher("# Doc\nContent.", TimeSpan.FromSeconds(10));
        var service = CreateService(fetcher);
 
        using var cts = new CancellationTokenSource();
        cts.Cancel();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => service.EnsureIndexedAsync(cts.Token).AsTask());
    }
 
    [Fact]
    public async Task EnsureIndexedAsync_WhenFetcherThrows_PropagatesException()
    {
        var fetcher = new ThrowingDocsFetcher(new InvalidOperationException("Critical error"));
        var service = CreateService(fetcher);
 
        await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsureIndexedAsync().AsTask());
    }
 
    [Fact]
    public async Task SearchAsync_WithSpecialCharactersInQuery_HandlesGracefully()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        // Should not throw
        var results = await service.SearchAsync("Redis!@#$%^&*()");
 
        // May or may not find results, but should not throw
        Assert.NotNull(results);
    }
 
    [Fact]
    public async Task SearchAsync_WithVeryLongQuery_HandlesGracefully()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var longQuery = new string('a', 10000);
 
        // Should not throw
        var results = await service.SearchAsync(longQuery);
 
        Assert.NotNull(results);
    }
 
    [Fact]
    public async Task GetDocumentAsync_WithNonExistentSection_ReturnsFullDocument()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Main content here.
 
            ## Installation
            Install content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var doc = await service.GetDocumentAsync("redis-integration", "NonExistentSection");
 
        Assert.NotNull(doc);
        // When section not found, returns full content
        Assert.Contains("Main content here", doc.Content);
    }
 
    [Fact]
    public async Task SearchAsync_WithZeroTopK_ReturnsEmptyResults()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis", topK: 0);
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task SearchAsync_WithNegativeTopK_ReturnsEmptyResults()
    {
        var content = """
            # Redis Integration
            > Connect to Redis.
 
            Redis content.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("Redis", topK: -1);
 
        Assert.Empty(results);
    }
 
    [Fact]
    public async Task SearchAsync_SlugExactMatch_RanksHigher()
    {
        // This tests the "service discovery" example from the issue
        // Query "service-discovery" should match slug "service-discovery" and rank #1
        var content = """
            # Service Discovery
            > Learn about service discovery in Aspire.
 
            Service discovery content.
 
            # Azure Service Bus
            > Connect to Azure Service Bus.
 
            Azure Service Bus has a service name.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("service-discovery");
 
        Assert.NotEmpty(results);
        Assert.Equal("Service Discovery", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_SlugPhraseMatch_RanksHigher()
    {
        // Query "service discovery" should match slug "service-discovery" with high score
        // and not "azure-service-bus" just because "service" appears in it
        var content = """
            # Service Discovery
            > Learn about service discovery in Aspire.
 
            Service discovery content.
 
            # Azure Service Bus
            > Connect to Azure Service Bus for messaging.
 
            Azure Service Bus documentation with lots of service mentions.
            Service is mentioned multiple times. Service again. And service.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("service discovery");
 
        Assert.NotEmpty(results);
        Assert.Equal("Service Discovery", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_WhatsNewPenalty_RanksLower()
    {
        // "What's New" pages mention many features and should rank lower than dedicated docs
        var content = """
            # JavaScript Integration
            > How to use JavaScript with Aspire.
 
            JavaScript integration details.
 
            # What's New in Aspire 1.3
            > Release notes for Aspire 1.3.
 
            JavaScript support was added. JavaScript is now fully supported.
            JavaScript JavaScript JavaScript. We love JavaScript!
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("javascript");
 
        Assert.NotEmpty(results);
        // The dedicated JavaScript doc should rank higher even though What's New mentions it more
        Assert.Equal("JavaScript Integration", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_PartialSlugMatch_StillRanksReasonably()
    {
        // Query with partial slug match should still rank well
        var content = """
            # Configure the MCP Server
            > How to configure MCP.
 
            MCP configuration details.
 
            # Aspire Dashboard Configuration
            > Dashboard configuration including MCP settings.
 
            The dashboard has MCP options in settings.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("mcp");
 
        Assert.NotEmpty(results);
        // The doc with "mcp" in the slug should rank higher
        Assert.Equal("Configure the MCP Server", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_ChangelogPenalty_AppliesCorrectly()
    {
        // Similar to whats-new, changelog pages should be penalized
        var content = """
            # Redis Integration
            > How to use Redis with Aspire.
 
            Redis integration details.
 
            # Changelog
            > Complete changelog for Aspire.
 
            Redis support was added. Redis improvements. More Redis features.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("redis");
 
        Assert.NotEmpty(results);
        // The dedicated Redis doc should rank higher than the changelog
        Assert.Equal("Redis Integration", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_MultiWordQuery_MatchesSlugSegments()
    {
        // Query "azure cosmos" should match slug "azure-cosmos-db" well
        var content = """
            # Azure Cosmos DB
            > Connect to Azure Cosmos DB.
 
            Cosmos content.
 
            # Azure Overview
            > General Azure services overview.
 
            Overview includes Cosmos DB mention.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("azure cosmos");
 
        Assert.NotEmpty(results);
        Assert.Equal("Azure Cosmos DB", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_SingleWordQuery_UsesSegmentMatching()
    {
        // Single-word query should use segment-based matching (10 points)
        // not phrase matching (30 points).
        // This ensures "service" is scored by segment matches so that docs with "service"
        // in the title and slug outrank docs where it only appears in the body.
        var content = """
            # Redis Integration
            > How to use Redis with Aspire.
 
            Redis integration details.
 
            # Azure Service Bus
            > Connect to Azure Service Bus.
 
            The service is for messaging. Redis is mentioned in the service docs.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("service");
 
        Assert.NotEmpty(results);
        // Both docs should return results, but Azure Service Bus should rank higher
        // because "service" is in the title AND as a slug segment
        Assert.Equal("Azure Service Bus", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_HyphenatedQuery_MatchesSlugWithExtraSegments()
    {
        // Query "service-bus" should match slug "azure-service-bus" 
        // even though it's a single token containing a hyphen
        var content = """
            # Azure Service Bus
            > Connect to Azure Service Bus.
 
            Service Bus content.
 
            # Azure Overview
            > General Azure services overview.
 
            Overview of Azure services.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("service-bus");
 
        Assert.NotEmpty(results);
        Assert.Equal("Azure Service Bus", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_ChangelogQuery_DoesNotApplyPenalty()
    {
        // When user searches for "changelog", the changelog page should NOT be penalized
        var content = """
            # Changelog
            > Complete changelog for Aspire.
 
            Version 1.0 changes. Version 2.0 changes.
 
            # Some Other Page
            > Random page.
 
            Changelog mentioned once.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("changelog");
 
        Assert.NotEmpty(results);
        // The dedicated Changelog page should rank highest when user searches for it
        Assert.Equal("Changelog", results[0].Title);
    }
 
    [Fact]
    public async Task SearchAsync_WhatsNewQuery_DoesNotApplyPenalty()
    {
        // When user searches for "whats new", the whats-new page should NOT be penalized
        var content = """
            # What's New in Aspire 1.3
            > Release notes for Aspire 1.3.
 
            New features and improvements.
 
            # Other Documentation
            > Some other docs.
 
            Nothing new here.
            """;
 
        var fetcher = CreateMockFetcher(content);
        var service = CreateService(fetcher);
 
        var results = await service.SearchAsync("whats new");
 
        Assert.NotEmpty(results);
        // The What's New page should rank highest when user searches for it
        Assert.Equal("What's New in Aspire 1.3", results[0].Title);
    }
 
    private sealed class MockDocsFetcher(string? content) : IDocsFetcher
    {
        public Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default)
        {
            return Task.FromResult(content);
        }
    }
 
    private sealed class CountingDocsFetcher(Func<string?> contentProvider) : IDocsFetcher
    {
        public Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default)
        {
            return Task.FromResult(contentProvider());
        }
    }
 
    private sealed class ThrowingDocsFetcher(Exception exception) : IDocsFetcher
    {
        public Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default)
        {
            throw exception;
        }
    }
 
    private sealed class DelayingDocsFetcher(string? content, TimeSpan delay) : IDocsFetcher
    {
        public async Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default)
        {
            await Task.Delay(delay, cancellationToken);
            return content;
        }
    }
 
    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;
    }
}