From 4b69c0cf79e6c0502cf4373e4815c0853ce6483a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:39:33 +0000 Subject: [PATCH] test: Add unit tests for BookToolQueryService Adds coverage for the MCP book-tool query service, which previously had no direct unit tests despite backing several MCP tools (GetChapterList, GetChapterSections, GetDirectContentUrl, GetNavigationContext, GetChapterSummary). Tests cover: - Successful chapter listing / section lookups with ordering - McpException thrown for unknown chapters and blank/unknown section keys - Navigation context previous/next/parent resolution - Guideline filtering by chapter number in chapter summaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BookToolQueryServiceTests.cs | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 EssentialCSharp.Web.Tests/BookToolQueryServiceTests.cs diff --git a/EssentialCSharp.Web.Tests/BookToolQueryServiceTests.cs b/EssentialCSharp.Web.Tests/BookToolQueryServiceTests.cs new file mode 100644 index 00000000..b918e1dc --- /dev/null +++ b/EssentialCSharp.Web.Tests/BookToolQueryServiceTests.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using Moq; + +namespace EssentialCSharp.Web.Tests; + +public class BookToolQueryServiceTests +{ + private static SiteMapping CreateSiteMapping( + int chapterNumber, + int pageNumber, + int orderOnPage, + string key, + string rawHeading, + int indentLevel, + string chapterTitle = "Test Chapter", + string? anchorId = null) + { + return new SiteMapping( + keys: [key], + primaryKey: key, + pagePath: ["Chapters", chapterNumber.ToString("00", CultureInfo.InvariantCulture), "Pages", $"{pageNumber:00}.html"], + chapterNumber: chapterNumber, + pageNumber: pageNumber, + orderOnPage: orderOnPage, + chapterTitle: chapterTitle, + rawHeading: rawHeading, + anchorId: anchorId ?? key, + indentLevel: indentLevel, + contentHash: "TestHash", + includeInSitemapXml: indentLevel == 0); + } + + private static (BookToolQueryService Service, Mock SiteMappingServiceMock, Mock GuidelinesServiceMock) CreateService( + IList? siteMappings = null, + IReadOnlyList? guidelines = null, + string? baseUrl = null) + { + Mock siteMappingServiceMock = new(); + siteMappingServiceMock.Setup(m => m.SiteMappings).Returns(siteMappings ?? []); + + Mock guidelinesServiceMock = new(); + guidelinesServiceMock.Setup(m => m.Guidelines).Returns(guidelines ?? []); + + SiteSettings siteSettings = new() { BaseUrl = baseUrl ?? "https://essentialcsharp.com" }; + IOptions options = Options.Create(siteSettings); + + BookToolQueryService service = new(siteMappingServiceMock.Object, guidelinesServiceMock.Object, options); + return (service, siteMappingServiceMock, guidelinesServiceMock); + } + + [Test] + public async Task GetChapterList_ReturnsTitleAndMappedTocData() + { + Mock siteMappingServiceMock = new(); + siteMappingServiceMock.Setup(m => m.SiteMappings).Returns((IList)[]); + siteMappingServiceMock.Setup(m => m.GetTocData()).Returns( + [ + new SiteMappingDto + { + Level = 0, + Key = "chapter-1", + Href = "chapter-1#intro", + Title = "Chapter 1: Introducing C#", + Items = [] + } + ]); + + Mock guidelinesServiceMock = new(); + guidelinesServiceMock.Setup(m => m.Guidelines).Returns((IReadOnlyList)[]); + + SiteSettings siteSettings = new() { BaseUrl = "https://essentialcsharp.com" }; + BookToolQueryService service = new(siteMappingServiceMock.Object, guidelinesServiceMock.Object, Options.Create(siteSettings)); + + ChapterListToolResult result = service.GetChapterList(); + + await Assert.That(result.Title).IsEqualTo("Essential C# - Table of Contents"); + await Assert.That(result.Chapters).Count().IsEqualTo(1); + await Assert.That(result.Chapters[0].Key).IsEqualTo("chapter-1"); + await Assert.That(result.Chapters[0].Url).IsEqualTo("https://essentialcsharp.com/chapter-1#intro"); + } + + [Test] + public async Task GetChapterSections_UnknownChapter_ThrowsMcpException() + { + (BookToolQueryService service, _, _) = CreateService(); + + await Assert.That(() => service.GetChapterSections(99)).Throws(); + } + + [Test] + public async Task GetChapterSections_KnownChapter_ReturnsOrderedSections() + { + List mappings = + [ + CreateSiteMapping(1, 2, 0, "page-2", "Second", 0), + CreateSiteMapping(1, 1, 0, "page-1", "First", 0), + ]; + (BookToolQueryService service, _, _) = CreateService(mappings); + + ChapterSectionsToolResult result = service.GetChapterSections(1); + + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.Sections).Count().IsEqualTo(2); + await Assert.That(result.Sections[0].Key).IsEqualTo("page-1"); + await Assert.That(result.Sections[1].Key).IsEqualTo("page-2"); + } + + [Test] + public async Task GetDirectContentUrl_BlankSectionKey_ThrowsMcpException() + { + (BookToolQueryService service, _, _) = CreateService(); + + await Assert.That(() => service.GetDirectContentUrl(" ")).Throws(); + } + + [Test] + public async Task GetDirectContentUrl_UnknownSectionKey_ThrowsMcpException() + { + (BookToolQueryService service, _, _) = CreateService(); + + await Assert.That(() => service.GetDirectContentUrl("does-not-exist")).Throws(); + } + + [Test] + public async Task GetDirectContentUrl_KnownSectionKey_ReturnsReference() + { + List mappings = [CreateSiteMapping(1, 1, 0, "page-1", "First", 0)]; + (BookToolQueryService service, _, _) = CreateService(mappings, baseUrl: "https://example.test/"); + + BookSectionReferenceResult result = service.GetDirectContentUrl("page-1"); + + await Assert.That(result.Key).IsEqualTo("page-1"); + await Assert.That(result.Url).IsEqualTo("https://example.test/page-1#page-1"); + } + + [Test] + public async Task GetNavigationContext_BlankSectionKey_ThrowsMcpException() + { + (BookToolQueryService service, _, _) = CreateService(); + + await Assert.That(() => service.GetNavigationContext(string.Empty)).Throws(); + } + + [Test] + public async Task GetNavigationContext_ReturnsPreviousNextAndParent() + { + List mappings = + [ + CreateSiteMapping(1, 1, 0, "chapter-1", "Chapter 1", 0), + CreateSiteMapping(1, 2, 0, "section-a", "Section A", 1), + CreateSiteMapping(1, 3, 0, "section-b", "Section B", 1), + CreateSiteMapping(1, 4, 0, "section-c", "Section C", 1), + ]; + (BookToolQueryService service, _, _) = CreateService(mappings); + + NavigationContextToolResult result = service.GetNavigationContext("section-b"); + + await Assert.That(result.Section.Key).IsEqualTo("section-b"); + await Assert.That(result.Previous!.Key).IsEqualTo("section-a"); + await Assert.That(result.Next!.Key).IsEqualTo("section-c"); + await Assert.That(result.Parent!.Key).IsEqualTo("chapter-1"); + } + + [Test] + public async Task GetChapterSummary_UnknownChapter_ThrowsMcpException() + { + (BookToolQueryService service, _, _) = CreateService(); + + await Assert.That(() => service.GetChapterSummary(42)).Throws(); + } + + [Test] + public async Task GetChapterSummary_FiltersGuidelinesByChapter() + { + List mappings = [CreateSiteMapping(1, 1, 0, "page-1", "First", 0)]; + List guidelines = + [ + new(GuidelineType.Do, "Do use meaningful names.", 1, "Chapter 1", "Naming", "Naming"), + new(GuidelineType.Avoid, "Avoid unrelated chapter guideline.", 2, "Chapter 2", "Other", "Other"), + ]; + (BookToolQueryService service, _, _) = CreateService(mappings, guidelines); + + ChapterSummaryToolResult result = service.GetChapterSummary(1); + + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.Guidelines).Count().IsEqualTo(1); + await Assert.That(result.Guidelines[0].Guideline).IsEqualTo("Do use meaningful names."); + } +}