From c36dba0e3e4969ee59e8db750d3ef15ceb9fbecb Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 11 Jul 2025 09:30:52 -0700 Subject: [PATCH 1/5] Adds sitemap generation Implements sitemap generation to improve SEO. Adds a new service to extract static routes from the application's controllers and actions. These routes are then used, along with content-based routes, to generate a sitemap.xml file in the wwwroot directory during application startup. This helps search engines crawl and index the site more effectively. --- Directory.Packages.props | 1 + .../RouteConfigurationServiceTests.cs | 65 +++++++++ .../Controllers/BaseController.cs | 22 +++ .../EssentialCSharp.Web.csproj | 1 + .../Helpers/SitemapXmlHelpers.cs | 137 ++++++++++++++++++ EssentialCSharp.Web/Program.cs | 22 ++- .../Services/IRouteConfigurationService.cs | 74 ++++++++++ 7 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs create mode 100644 EssentialCSharp.Web/Controllers/BaseController.cs create mode 100644 EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs create mode 100644 EssentialCSharp.Web/Services/IRouteConfigurationService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 182f7e19..30cf682f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,6 +40,7 @@ + diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs new file mode 100644 index 00000000..c8b7277d --- /dev/null +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -0,0 +1,65 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Tests; + +public class RouteConfigurationServiceTests : IClassFixture> +{ + private readonly WebApplicationFactory _Factory; + private readonly IRouteConfigurationService _RouteConfigurationService; + + public RouteConfigurationServiceTests(WebApplicationFactory factory) + { + _Factory = factory; + + // Get the service from the DI container to test with real routes + var scope = _Factory.Services.CreateScope(); + _RouteConfigurationService = scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public void GetStaticRoutes_ShouldReturnExpectedRoutes() + { + // Act + var routes = _RouteConfigurationService.GetStaticRoutes().ToList(); + + // Assert + Assert.NotEmpty(routes); + + // Check for expected routes from the HomeController + Assert.Contains("home", routes); + Assert.Contains("about", routes); + Assert.Contains("guidelines", routes); + Assert.Contains("announcements", routes); + Assert.Contains("termsofservice", routes); + } + + [Fact] + public void GetStaticRoutes_ShouldIncludeAllHomeControllerRoutes() + { + // Act + var routes = _RouteConfigurationService.GetStaticRoutes().ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Assert - check all expected routes from HomeController + var expectedRoutes = new[] { "home", "about", "guidelines", "announcements", "termsofservice" }; + + foreach (var expectedRoute in expectedRoutes) + { + Assert.True(routes.Contains(expectedRoute), + $"Expected route '{expectedRoute}' was not found in discovered routes: [{string.Join(", ", routes)}]"); + } + } + + [Fact] + public void GetStaticRoutes_ShouldNotIncludeIdentityRoutes() + { + // Act + var routes = _RouteConfigurationService.GetStaticRoutes(); + + // Assert - ensure no Identity area routes are included + Assert.DoesNotContain("identity", routes, StringComparer.OrdinalIgnoreCase); + } + + +} diff --git a/EssentialCSharp.Web/Controllers/BaseController.cs b/EssentialCSharp.Web/Controllers/BaseController.cs new file mode 100644 index 00000000..f6807d3e --- /dev/null +++ b/EssentialCSharp.Web/Controllers/BaseController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using EssentialCSharp.Web.Services; + +namespace EssentialCSharp.Web.Controllers; + +public abstract class BaseController : Controller +{ + private readonly IRouteConfigurationService _routeConfigurationService; + + protected BaseController(IRouteConfigurationService routeConfigurationService) + { + _routeConfigurationService = routeConfigurationService; + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + // Automatically add static routes to all views + ViewBag.StaticRoutes = System.Text.Json.JsonSerializer.Serialize(_routeConfigurationService.GetStaticRoutes()); + base.OnActionExecuting(context); + } +} diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 4cb85e02..2c2b6426 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -35,6 +35,7 @@ + diff --git a/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs new file mode 100644 index 00000000..94dc6a35 --- /dev/null +++ b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs @@ -0,0 +1,137 @@ +using DotnetSitemapGenerator; +using DotnetSitemapGenerator.Serialization; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace EssentialCSharp.Web.Helpers; + +public static class SitemapXmlHelpers +{ + private const string RootUrl = "https://essentialcsharp.com/"; + + public static void EnsureSitemapHealthy(List siteMappings) + { + var groups = siteMappings.GroupBy(item => new { item.ChapterNumber, item.PageNumber }); + foreach (var group in groups) + { + try + { + SiteMapping result = group.Single(item => item.IncludeInSitemapXml); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Sitemap error: Chapter {group.Key.ChapterNumber}, Page {group.Key.PageNumber} has more than one canonical link, or none: {ex.Message}", ex); + } + } + } + + public static void GenerateAndSerializeSitemapXml(DirectoryInfo wwwrootDirectory, List siteMappings, ILogger logger, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + { + GenerateSitemapXml(wwwrootDirectory, siteMappings, actionDescriptorCollectionProvider, out string xmlPath, out List nodes); + XmlSerializer sitemapProvider = new(); + sitemapProvider.Serialize(new SitemapModel(nodes), xmlPath, true); + logger.LogInformation("sitemap.xml successfully written to {XmlPath}", xmlPath); + } + + public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List siteMappings, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, out string xmlPath, out List nodes) + { + xmlPath = Path.Combine(wwwrootDirectory.FullName, "sitemap.xml"); + DateTime newDateTime = DateTime.UtcNow; + + // Start with the root URL + nodes = new() { + new($"{RootUrl}") + { + LastModificationDate = newDateTime, + ChangeFrequency = ChangeFrequency.Daily, + Priority = 1.0M + } + }; + + // Add routes dynamically discovered from controllers (excluding Identity routes) + var controllerRoutes = GetControllerRoutes(actionDescriptorCollectionProvider); + foreach (var route in controllerRoutes) + { + nodes.Add(new($"{RootUrl.TrimEnd('/')}{route}") + { + LastModificationDate = newDateTime, + ChangeFrequency = GetChangeFrequencyForRoute(route), + Priority = GetPriorityForRoute(route) + }); + } + + // Add site mappings from content + nodes.AddRange(siteMappings.Where(item => item.IncludeInSitemapXml).Select(siteMapping => new($"{RootUrl}{siteMapping.Keys.First()}") + { + LastModificationDate = newDateTime, + ChangeFrequency = ChangeFrequency.Daily, + Priority = 0.8M + })); + } + + private static List GetControllerRoutes(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + { + var routes = new List(); + + foreach (var actionDescriptor in actionDescriptorCollectionProvider.ActionDescriptors.Items) + { + // Skip Identity area routes + if (actionDescriptor.RouteValues.TryGetValue("area", out var area) && area == "Identity") + continue; + + // Skip the default fallback route (Index action in HomeController) + if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index") + continue; + + // Skip Error actions + if (action == "Error") + continue; + + // Get the route template or attribute route + if (actionDescriptor.AttributeRouteInfo?.Template is string template) + { + // Clean up the template (remove parameters, etc.) + var cleanRoute = template.TrimStart('/'); + if (!string.IsNullOrEmpty(cleanRoute) && !routes.Contains($"/{cleanRoute}")) + { + routes.Add($"/{cleanRoute}"); + } + } + } + + return routes.Distinct().OrderBy(r => r).ToList(); + } + + private static ChangeFrequency GetChangeFrequencyForRoute(string route) + { + return route.ToLowerInvariant() switch + { + "/termsofservice" => ChangeFrequency.Yearly, + "/announcements" => ChangeFrequency.Monthly, + "/guidelines" => ChangeFrequency.Monthly, + _ => ChangeFrequency.Monthly + }; + } + + private static decimal GetPriorityForRoute(string route) + { + return route.ToLowerInvariant() switch + { + "/home" => 0.5M, + "/about" => 0.5M, + "/announcements" => 0.5M, + "/guidelines" => 0.9M, + "/termsofservice" => 0.2M, + _ => 0.5M + }; + } +} + +public class InvalidItemException : Exception +{ + public InvalidItemException(string? message) : base(message) + { + } + public InvalidItemException(string? message, Exception exception) : base(message, exception) + { + } +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 56f97373..37807b9e 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -3,6 +3,7 @@ using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Helpers; using EssentialCSharp.Web.Middleware; using EssentialCSharp.Web.Services; using EssentialCSharp.Web.Services.Referrals; @@ -10,6 +11,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.EntityFrameworkCore; namespace EssentialCSharp.Web; @@ -146,6 +148,7 @@ private static void Main(string[] args) builder.Services.AddRazorPages(); builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); @@ -182,7 +185,6 @@ private static void Main(string[] args) WebApplication app = builder.Build(); - // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -215,6 +217,24 @@ private static void Main(string[] args) app.MapFallbackToController("Index", "Home"); + // Generate sitemap.xml at startup + var wwwrootDirectory = new DirectoryInfo(app.Environment.WebRootPath); + var siteMappingService = app.Services.GetRequiredService(); + var actionDescriptorCollectionProvider = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService>(); + + try + { + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); + SitemapXmlHelpers.GenerateAndSerializeSitemapXml(wwwrootDirectory, siteMappingService.SiteMappings.ToList(), logger, actionDescriptorCollectionProvider); + logger.LogInformation("Sitemap.xml generation completed successfully during application startup"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to generate sitemap.xml during application startup"); + // Continue startup even if sitemap generation fails + } + app.Run(); } } diff --git a/EssentialCSharp.Web/Services/IRouteConfigurationService.cs b/EssentialCSharp.Web/Services/IRouteConfigurationService.cs new file mode 100644 index 00000000..e4a61fad --- /dev/null +++ b/EssentialCSharp.Web/Services/IRouteConfigurationService.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using System.Reflection; + +namespace EssentialCSharp.Web.Services; + +public interface IRouteConfigurationService +{ + IEnumerable GetStaticRoutes(); +} + +public class RouteConfigurationService : IRouteConfigurationService +{ + private readonly IActionDescriptorCollectionProvider _ActionDescriptorCollectionProvider; + private readonly HashSet _StaticRoutes; + + public RouteConfigurationService(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + { + _ActionDescriptorCollectionProvider = actionDescriptorCollectionProvider; + _StaticRoutes = ExtractStaticRoutes(); + } + + public IEnumerable GetStaticRoutes() + { + return _StaticRoutes; + } + + private HashSet ExtractStaticRoutes() + { + var routes = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Get all action descriptors + var actionDescriptors = _ActionDescriptorCollectionProvider.ActionDescriptors.Items; + + foreach (var actionDescriptor in actionDescriptors) + { + // Look for route attributes + if (actionDescriptor.AttributeRouteInfo?.Template != null) + { + string template = actionDescriptor.AttributeRouteInfo.Template; + + // Remove leading slash and add to our set + string routePath = template.TrimStart('/').ToLowerInvariant(); + routes.Add(routePath); + } + + // Skip Identity area routes + if (actionDescriptor.RouteValues.TryGetValue("area", out var area) && area == "Identity") + continue; + + // Skip the default fallback route (Index action in HomeController) + if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index") + continue; + + // Skip Error actions + if (action == "Error") + continue; + + // For actions without attribute routes, use conventional routing + if (actionDescriptor.AttributeRouteInfo?.Template == null && + actionDescriptor.RouteValues.TryGetValue("action", out var actionName) && + actionDescriptor.RouteValues.TryGetValue("controller", out var controllerName)) + { + if (controllerName?.Equals("Home", StringComparison.OrdinalIgnoreCase) == true && actionName != null) + { + // Use the action name directly as the route + routes.Add(actionName.ToLowerInvariant()); + } + } + } + + return routes; + } +} From 4afc52f642c4ed1f7fcf0825b8e10607238bfbf8 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 11 Jul 2025 09:34:22 -0700 Subject: [PATCH 2/5] Updates logging for Application Insights. Ensures the initial logger is used when warning about a missing Application Insights connection string. This provides a more accurate representation of the application's startup phase. --- EssentialCSharp.Web/Program.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 37807b9e..8ee15963 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -43,7 +43,7 @@ private static void Main(string[] args) // Create a temporary logger for startup logging using var loggerFactory = LoggerFactory.Create(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Information)); - var logger = loggerFactory.CreateLogger(); + var initialLogger = loggerFactory.CreateLogger(); if (!builder.Environment.IsDevelopment()) { @@ -53,17 +53,17 @@ private static void Main(string[] args) if (!string.IsNullOrEmpty(appInsightsConnectionString)) { - builder.Services.AddOpenTelemetry().UseAzureMonitor( - options => - { - options.ConnectionString = appInsightsConnectionString; - }); + builder.Services.AddOpenTelemetry().UseAzureMonitor( + options => + { + options.ConnectionString = appInsightsConnectionString; + }); builder.Services.AddApplicationInsightsTelemetry(); builder.Services.AddServiceProfiler(); } else { - logger.LogWarning("Application Insights connection string not found. Telemetry collection will be disabled."); + initialLogger.LogWarning("Application Insights connection string not found. Telemetry collection will be disabled."); } } From 9e398c364bca34d71f3b23b60a4abd7703a20674 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 11 Jul 2025 09:47:18 -0700 Subject: [PATCH 3/5] Updates WebApplicationFactory usage Updates the test class to use the correct WebApplicationFactory type. This ensures proper dependency injection and test setup. --- EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs index c8b7277d..ee7b3715 100644 --- a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -4,12 +4,12 @@ namespace EssentialCSharp.Web.Tests; -public class RouteConfigurationServiceTests : IClassFixture> +public class RouteConfigurationServiceTests : IClassFixture { - private readonly WebApplicationFactory _Factory; + private readonly WebApplicationFactory _Factory; private readonly IRouteConfigurationService _RouteConfigurationService; - public RouteConfigurationServiceTests(WebApplicationFactory factory) + internal RouteConfigurationServiceTests(WebApplicationFactory factory) { _Factory = factory; From b06a9ea7354400e4fd60be43948e64555b0916fb Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 11 Jul 2025 11:37:03 -0700 Subject: [PATCH 4/5] Improves sitemap generation and route handling. Refactors sitemap XML generation to use a dedicated service for route configuration. This change improves the sitemap generation process by: - Introducing `IRouteConfigurationService` to centralize route retrieval and filtering logic. - Excluding Identity, Index, and Error routes from the sitemap. - Adding tests to validate sitemap generation. - Configuring the base URL for sitemap generation through configuration. - Ensuring sitemap health by validating canonical links. This approach provides better control over the routes included in the sitemap and enhances the overall maintainability and testability of the sitemap generation process. --- .../RouteConfigurationServiceTests.cs | 42 +-- .../SitemapXmlHelpersTests.cs | 285 ++++++++++++++++++ .../WebApplicationFactory.cs | 26 +- .../Controllers/BaseController.cs | 8 +- .../Helpers/SitemapXmlHelpers.cs | 75 ++--- EssentialCSharp.Web/Program.cs | 10 +- .../Services/IRouteConfigurationService.cs | 67 +--- .../Services/RouteConfigurationService.cs | 63 ++++ .../appsettings.Development.json | 3 + EssentialCSharp.Web/appsettings.json | 3 + 10 files changed, 422 insertions(+), 160 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs create mode 100644 EssentialCSharp.Web/Services/RouteConfigurationService.cs diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs index ee7b3715..9280e3fd 100644 --- a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -1,5 +1,4 @@ using EssentialCSharp.Web.Services; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; namespace EssentialCSharp.Web.Tests; @@ -7,22 +6,21 @@ namespace EssentialCSharp.Web.Tests; public class RouteConfigurationServiceTests : IClassFixture { private readonly WebApplicationFactory _Factory; - private readonly IRouteConfigurationService _RouteConfigurationService; - internal RouteConfigurationServiceTests(WebApplicationFactory factory) + public RouteConfigurationServiceTests(WebApplicationFactory factory) { _Factory = factory; - - // Get the service from the DI container to test with real routes - var scope = _Factory.Services.CreateScope(); - _RouteConfigurationService = scope.ServiceProvider.GetRequiredService(); } [Fact] public void GetStaticRoutes_ShouldReturnExpectedRoutes() { // Act - var routes = _RouteConfigurationService.GetStaticRoutes().ToList(); + var routes = _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + return routeConfigurationService.GetStaticRoutes().ToList(); + }); // Assert Assert.NotEmpty(routes); @@ -34,32 +32,4 @@ public void GetStaticRoutes_ShouldReturnExpectedRoutes() Assert.Contains("announcements", routes); Assert.Contains("termsofservice", routes); } - - [Fact] - public void GetStaticRoutes_ShouldIncludeAllHomeControllerRoutes() - { - // Act - var routes = _RouteConfigurationService.GetStaticRoutes().ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Assert - check all expected routes from HomeController - var expectedRoutes = new[] { "home", "about", "guidelines", "announcements", "termsofservice" }; - - foreach (var expectedRoute in expectedRoutes) - { - Assert.True(routes.Contains(expectedRoute), - $"Expected route '{expectedRoute}' was not found in discovered routes: [{string.Join(", ", routes)}]"); - } - } - - [Fact] - public void GetStaticRoutes_ShouldNotIncludeIdentityRoutes() - { - // Act - var routes = _RouteConfigurationService.GetStaticRoutes(); - - // Assert - ensure no Identity area routes are included - Assert.DoesNotContain("identity", routes, StringComparer.OrdinalIgnoreCase); - } - - } diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs new file mode 100644 index 00000000..94b4bca0 --- /dev/null +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -0,0 +1,285 @@ +using System.Globalization; +using DotnetSitemapGenerator; +using EssentialCSharp.Web.Helpers; +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EssentialCSharp.Web.Tests; + +public class SitemapXmlHelpersTests : IClassFixture +{ + private readonly WebApplicationFactory _Factory; + + public SitemapXmlHelpersTests(WebApplicationFactory factory) + { + _Factory = factory; + } + + [Fact] + public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() + { + // Arrange + var siteMappings = new List + { + CreateSiteMapping(1, 1, true), + CreateSiteMapping(1, 2, true), + CreateSiteMapping(2, 1, true) + }; + + // Act & Assert + var exception = Record.Exception(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); + Assert.Null(exception); + } + + [Fact] + public void EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsException() + { + // Arrange - Two mappings for the same chapter/page both marked as canonical + var siteMappings = new List + { + CreateSiteMapping(1, 1, true), + CreateSiteMapping(1, 1, true) // Same chapter/page, also canonical + }; + + // Act & Assert + var exception = Assert.Throws(() => + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); + + Assert.Contains("Chapter 1, Page 1", exception.Message); + Assert.Contains("more than one canonical link", exception.Message); + } + + [Fact] + public void EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException() + { + // Arrange - No mappings marked as canonical for this page + var siteMappings = new List + { + CreateSiteMapping(1, 1, false), + CreateSiteMapping(1, 1, false) // Same chapter/page, neither canonical + }; + + // Act & Assert + var exception = Assert.Throws(() => + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); + + Assert.Contains("Chapter 1, Page 1", exception.Message); + } + + [Fact] + public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes() + { + // Arrange + var tempDir = new DirectoryInfo(Path.GetTempPath()); + var siteMappings = new List { CreateSiteMapping(1, 1, true) }; + var baseUrl = "https://test.example.com/"; + + // Act & Assert + var nodes = _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); + return nodes; + }); + + var allUrls = nodes.Select(n => n.Url).ToList(); + + // Verify no Identity routes are included + Assert.DoesNotContain(allUrls, url => url.Contains("Identity", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(allUrls, url => url.Contains("Account", StringComparison.OrdinalIgnoreCase)); + + // But verify that expected routes are included + Assert.Contains(allUrls, url => url.Contains("/home", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(allUrls, url => url.Contains("/about", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void GenerateSitemapXml_IncludesBaseUrl() + { + // Arrange + var tempDir = new DirectoryInfo(Path.GetTempPath()); + var siteMappings = new List(); + var baseUrl = "https://test.example.com/"; + + // Act & Assert + var nodes = _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); + return nodes; + }); + + Assert.Contains(nodes, node => node.Url == baseUrl); + + // Verify the root URL has highest priority + var rootNode = nodes.First(node => node.Url == baseUrl); + Assert.Equal(1.0M, rootNode.Priority); + Assert.Equal(ChangeFrequency.Daily, rootNode.ChangeFrequency); + } + + [Fact] + public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() + { + // Arrange + var tempDir = new DirectoryInfo(Path.GetTempPath()); + var baseUrl = "https://test.example.com/"; + + var siteMappings = new List + { + CreateSiteMapping(1, 1, true, "test-page-1"), + CreateSiteMapping(1, 2, false, "test-page-2"), // Not included in XML + CreateSiteMapping(2, 1, true, "test-page-3") + }; + + // Act & Assert + _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); + + var allUrls = nodes.Select(n => n.Url).ToList(); + + Assert.Contains(allUrls, url => url.Contains("test-page-1")); + Assert.DoesNotContain(allUrls, url => url.Contains("test-page-2")); // Not marked for XML + Assert.Contains(allUrls, url => url.Contains("test-page-3")); + }); + } + + [Fact] + public void GenerateSitemapXml_DoesNotIncludeIndexRoutes() + { + // Arrange + var tempDir = new DirectoryInfo(Path.GetTempPath()); + var siteMappings = new List(); + var baseUrl = "https://test.example.com/"; + + // Act & Assert + _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); + + var allUrls = nodes.Select(n => n.Url).ToList(); + + // Should not include Index action routes (they're the default) + Assert.DoesNotContain(allUrls, url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase)); + }); + } + + [Fact] + public void GenerateSitemapXml_DoesNotIncludeErrorRoutes() + { + // Arrange + var tempDir = new DirectoryInfo(Path.GetTempPath()); + var siteMappings = new List(); + var baseUrl = "https://test.example.com/"; + + // Act & Assert + _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); + + var allUrls = nodes.Select(n => n.Url).ToList(); + + // Should not include Error action routes + Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase)); + }); + } + + [Fact] + public void GenerateAndSerializeSitemapXml_CreatesFileSuccessfully() + { + // Arrange + var logger = _Factory.Services.GetRequiredService>(); + var tempDir = new DirectoryInfo(Path.GetTempPath()); + var siteMappings = new List { CreateSiteMapping(1, 1, true) }; + var baseUrl = "https://test.example.com/"; + + // Clean up any existing file + var expectedXmlPath = Path.Combine(tempDir.FullName, "sitemap.xml"); + if (File.Exists(expectedXmlPath)) + { + File.Delete(expectedXmlPath); + } + + try + { + // Act + _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + SitemapXmlHelpers.GenerateAndSerializeSitemapXml( + tempDir, + siteMappings, + logger, + routeConfigurationService, + baseUrl); + }); + + // Assert + Assert.True(File.Exists(expectedXmlPath)); + + var xmlContent = File.ReadAllText(expectedXmlPath); + Assert.Contains(" +public sealed class WebApplicationFactory : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -36,4 +36,28 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) db.Database.EnsureCreated(); }); } + + /// + /// Executes an action within a service scope, handling scope creation and cleanup automatically. + /// + /// The return type of the action + /// The action to execute with the scoped service provider + /// The result of the action + public T InServiceScope(Func action) + { + var factory = Services.GetRequiredService(); + using var scope = factory.CreateScope(); + return action(scope.ServiceProvider); + } + + /// + /// Executes an action within a service scope, handling scope creation and cleanup automatically. + /// + /// The action to execute with the scoped service provider + public void InServiceScope(Action action) + { + var factory = Services.GetRequiredService(); + using var scope = factory.CreateScope(); + action(scope.ServiceProvider); + } } diff --git a/EssentialCSharp.Web/Controllers/BaseController.cs b/EssentialCSharp.Web/Controllers/BaseController.cs index f6807d3e..f1bdbb89 100644 --- a/EssentialCSharp.Web/Controllers/BaseController.cs +++ b/EssentialCSharp.Web/Controllers/BaseController.cs @@ -1,22 +1,22 @@ +using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using EssentialCSharp.Web.Services; namespace EssentialCSharp.Web.Controllers; public abstract class BaseController : Controller { - private readonly IRouteConfigurationService _routeConfigurationService; + private readonly IRouteConfigurationService _RouteConfigurationService; protected BaseController(IRouteConfigurationService routeConfigurationService) { - _routeConfigurationService = routeConfigurationService; + _RouteConfigurationService = routeConfigurationService; } public override void OnActionExecuting(ActionExecutingContext context) { // Automatically add static routes to all views - ViewBag.StaticRoutes = System.Text.Json.JsonSerializer.Serialize(_routeConfigurationService.GetStaticRoutes()); + ViewBag.StaticRoutes = System.Text.Json.JsonSerializer.Serialize(_RouteConfigurationService.GetStaticRoutes()); base.OnActionExecuting(context); } } diff --git a/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs index 94dc6a35..380206b9 100644 --- a/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs +++ b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs @@ -1,45 +1,43 @@ using DotnetSitemapGenerator; using DotnetSitemapGenerator.Serialization; -using Microsoft.AspNetCore.Mvc.Infrastructure; +using EssentialCSharp.Web.Services; namespace EssentialCSharp.Web.Helpers; public static class SitemapXmlHelpers { - private const string RootUrl = "https://essentialcsharp.com/"; - public static void EnsureSitemapHealthy(List siteMappings) { var groups = siteMappings.GroupBy(item => new { item.ChapterNumber, item.PageNumber }); foreach (var group in groups) { - try - { - SiteMapping result = group.Single(item => item.IncludeInSitemapXml); - } - catch (Exception ex) + var count = group.Count(item => item.IncludeInSitemapXml); + if (count != 1) { - throw new InvalidOperationException($"Sitemap error: Chapter {group.Key.ChapterNumber}, Page {group.Key.PageNumber} has more than one canonical link, or none: {ex.Message}", ex); + throw new InvalidOperationException($"Sitemap error: Chapter {group.Key.ChapterNumber}, Page {group.Key.PageNumber} has more than one canonical link, or none"); } } } - public static void GenerateAndSerializeSitemapXml(DirectoryInfo wwwrootDirectory, List siteMappings, ILogger logger, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + public static void GenerateAndSerializeSitemapXml(DirectoryInfo wwwrootDirectory, List siteMappings, ILogger logger, IRouteConfigurationService routeConfigurationService, string baseUrl) { - GenerateSitemapXml(wwwrootDirectory, siteMappings, actionDescriptorCollectionProvider, out string xmlPath, out List nodes); + GenerateSitemapXml(wwwrootDirectory, siteMappings, routeConfigurationService, baseUrl, out List nodes); XmlSerializer sitemapProvider = new(); + var xmlPath = Path.Combine(wwwrootDirectory.FullName, "sitemap.xml"); sitemapProvider.Serialize(new SitemapModel(nodes), xmlPath, true); logger.LogInformation("sitemap.xml successfully written to {XmlPath}", xmlPath); } - public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List siteMappings, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, out string xmlPath, out List nodes) + public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List siteMappings, IRouteConfigurationService routeConfigurationService, string baseUrl, out List nodes) { - xmlPath = Path.Combine(wwwrootDirectory.FullName, "sitemap.xml"); DateTime newDateTime = DateTime.UtcNow; + // Routes should end up with leading slash + baseUrl = baseUrl.TrimEnd('/'); + // Start with the root URL nodes = new() { - new($"{RootUrl}") + new($"{baseUrl}/") { LastModificationDate = newDateTime, ChangeFrequency = ChangeFrequency.Daily, @@ -47,11 +45,19 @@ public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List !route.Contains("error", StringComparison.OrdinalIgnoreCase)) // Skip Error actions for sitemap + .Where(route => !route.Contains("index", StringComparison.OrdinalIgnoreCase)) // Skip Index actions for sitemap + .Where(route => !route.Contains("identity", StringComparison.OrdinalIgnoreCase)) // Skip Identity actions for sitemap + // All routes should have leading slash + .Select(route => $"/{route}") // Add leading slash for sitemap URLs + .ToList(); + foreach (var route in controllerRoutes) { - nodes.Add(new($"{RootUrl.TrimEnd('/')}{route}") + nodes.Add(new($"{baseUrl}{route}") { LastModificationDate = newDateTime, ChangeFrequency = GetChangeFrequencyForRoute(route), @@ -60,7 +66,7 @@ public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List item.IncludeInSitemapXml).Select(siteMapping => new($"{RootUrl}{siteMapping.Keys.First()}") + nodes.AddRange(siteMappings.Where(item => item.IncludeInSitemapXml).Select(siteMapping => new($"{baseUrl.TrimEnd('/')}/{siteMapping.Keys.First()}") { LastModificationDate = newDateTime, ChangeFrequency = ChangeFrequency.Daily, @@ -68,39 +74,6 @@ public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List GetControllerRoutes(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) - { - var routes = new List(); - - foreach (var actionDescriptor in actionDescriptorCollectionProvider.ActionDescriptors.Items) - { - // Skip Identity area routes - if (actionDescriptor.RouteValues.TryGetValue("area", out var area) && area == "Identity") - continue; - - // Skip the default fallback route (Index action in HomeController) - if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index") - continue; - - // Skip Error actions - if (action == "Error") - continue; - - // Get the route template or attribute route - if (actionDescriptor.AttributeRouteInfo?.Template is string template) - { - // Clean up the template (remove parameters, etc.) - var cleanRoute = template.TrimStart('/'); - if (!string.IsNullOrEmpty(cleanRoute) && !routes.Contains($"/{cleanRoute}")) - { - routes.Add($"/{cleanRoute}"); - } - } - } - - return routes.Distinct().OrderBy(r => r).ToList(); - } - private static ChangeFrequency GetChangeFrequencyForRoute(string route) { return route.ToLowerInvariant() switch diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 8ee15963..3ae9be23 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -220,13 +220,19 @@ private static void Main(string[] args) // Generate sitemap.xml at startup var wwwrootDirectory = new DirectoryInfo(app.Environment.WebRootPath); var siteMappingService = app.Services.GetRequiredService(); - var actionDescriptorCollectionProvider = app.Services.GetRequiredService(); var logger = app.Services.GetRequiredService>(); + // Extract base URL from configuration + var baseUrl = configuration.GetSection("SiteSettings")["BaseUrl"] ?? "https://essentialcsharp.com"; + try { + // Create a scope to resolve scoped services + using var scope = app.Services.CreateScope(); + var routeConfigurationService = scope.ServiceProvider.GetRequiredService(); + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); - SitemapXmlHelpers.GenerateAndSerializeSitemapXml(wwwrootDirectory, siteMappingService.SiteMappings.ToList(), logger, actionDescriptorCollectionProvider); + SitemapXmlHelpers.GenerateAndSerializeSitemapXml(wwwrootDirectory, siteMappingService.SiteMappings.ToList(), logger, routeConfigurationService, baseUrl); logger.LogInformation("Sitemap.xml generation completed successfully during application startup"); } catch (Exception ex) diff --git a/EssentialCSharp.Web/Services/IRouteConfigurationService.cs b/EssentialCSharp.Web/Services/IRouteConfigurationService.cs index e4a61fad..10258ccb 100644 --- a/EssentialCSharp.Web/Services/IRouteConfigurationService.cs +++ b/EssentialCSharp.Web/Services/IRouteConfigurationService.cs @@ -1,74 +1,9 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; using System.Reflection; namespace EssentialCSharp.Web.Services; public interface IRouteConfigurationService { - IEnumerable GetStaticRoutes(); -} - -public class RouteConfigurationService : IRouteConfigurationService -{ - private readonly IActionDescriptorCollectionProvider _ActionDescriptorCollectionProvider; - private readonly HashSet _StaticRoutes; - - public RouteConfigurationService(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) - { - _ActionDescriptorCollectionProvider = actionDescriptorCollectionProvider; - _StaticRoutes = ExtractStaticRoutes(); - } - - public IEnumerable GetStaticRoutes() - { - return _StaticRoutes; - } - - private HashSet ExtractStaticRoutes() - { - var routes = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Get all action descriptors - var actionDescriptors = _ActionDescriptorCollectionProvider.ActionDescriptors.Items; - - foreach (var actionDescriptor in actionDescriptors) - { - // Look for route attributes - if (actionDescriptor.AttributeRouteInfo?.Template != null) - { - string template = actionDescriptor.AttributeRouteInfo.Template; - - // Remove leading slash and add to our set - string routePath = template.TrimStart('/').ToLowerInvariant(); - routes.Add(routePath); - } - - // Skip Identity area routes - if (actionDescriptor.RouteValues.TryGetValue("area", out var area) && area == "Identity") - continue; - - // Skip the default fallback route (Index action in HomeController) - if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index") - continue; - - // Skip Error actions - if (action == "Error") - continue; - - // For actions without attribute routes, use conventional routing - if (actionDescriptor.AttributeRouteInfo?.Template == null && - actionDescriptor.RouteValues.TryGetValue("action", out var actionName) && - actionDescriptor.RouteValues.TryGetValue("controller", out var controllerName)) - { - if (controllerName?.Equals("Home", StringComparison.OrdinalIgnoreCase) == true && actionName != null) - { - // Use the action name directly as the route - routes.Add(actionName.ToLowerInvariant()); - } - } - } - - return routes; - } + IReadOnlySet GetStaticRoutes(); } diff --git a/EssentialCSharp.Web/Services/RouteConfigurationService.cs b/EssentialCSharp.Web/Services/RouteConfigurationService.cs new file mode 100644 index 00000000..0429d6b0 --- /dev/null +++ b/EssentialCSharp.Web/Services/RouteConfigurationService.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace EssentialCSharp.Web.Services; + +public class RouteConfigurationService : IRouteConfigurationService +{ + private readonly IActionDescriptorCollectionProvider _ActionDescriptorCollectionProvider; + private readonly HashSet _StaticRoutes; + + public RouteConfigurationService(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) + { + _ActionDescriptorCollectionProvider = actionDescriptorCollectionProvider; + _StaticRoutes = ExtractStaticRoutes(); + } + + public IReadOnlySet GetStaticRoutes() + { + return _StaticRoutes; + } + + private HashSet ExtractStaticRoutes() + { + var routes = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Get all action descriptors + var actionDescriptors = _ActionDescriptorCollectionProvider.ActionDescriptors.Items; + + foreach (var actionDescriptor in actionDescriptors) + { + // Look for route attributes + if (actionDescriptor.AttributeRouteInfo?.Template != null) + { + string template = actionDescriptor.AttributeRouteInfo.Template; + + // Remove leading slash and add to our set + string routePath = template.TrimStart('/').ToLowerInvariant(); + routes.Add(routePath); + } + + // Skip the default fallback route (Index action in HomeController) + if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index") + continue; + + // Skip Error actions + if (action == "Error") + continue; + + // For actions without attribute routes, use conventional routing + if (actionDescriptor.AttributeRouteInfo?.Template == null && + actionDescriptor.RouteValues.TryGetValue("action", out var actionName) && + actionDescriptor.RouteValues.TryGetValue("controller", out var controllerName)) + { + if (controllerName?.Equals("Home", StringComparison.OrdinalIgnoreCase) == true && actionName != null) + { + // Use the action name directly as the route + routes.Add(actionName.ToLowerInvariant()); + } + } + } + + return routes; + } +} diff --git a/EssentialCSharp.Web/appsettings.Development.json b/EssentialCSharp.Web/appsettings.Development.json index 9f4ebdc8..f7e1d576 100644 --- a/EssentialCSharp.Web/appsettings.Development.json +++ b/EssentialCSharp.Web/appsettings.Development.json @@ -8,5 +8,8 @@ }, "ConnectionStrings": { "EssentialCSharpWebContextConnection": "Server=localhost;Database=EssentialCSharp.Web;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=true;" + }, + "SiteSettings": { + "BaseUrl": "https://localhost:7184" } } diff --git a/EssentialCSharp.Web/appsettings.json b/EssentialCSharp.Web/appsettings.json index 711f6a36..f7622d36 100644 --- a/EssentialCSharp.Web/appsettings.json +++ b/EssentialCSharp.Web/appsettings.json @@ -9,5 +9,8 @@ "HCaptcha": { "SecretKey": "0x0000000000000000000000000000000000000000", "SiteKey": "10000000-ffff-ffff-ffff-000000000001" + }, + "SiteSettings": { + "BaseUrl": "https://essentialcsharp.com" } } \ No newline at end of file From fab53605a1a74495a13049533d330a31229133ad Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 24 Jul 2025 00:48:13 -0700 Subject: [PATCH 5/5] PR Feedback --- .../SitemapXmlHelpersTests.cs | 134 +++++++----------- .../Helpers/SitemapXmlHelpers.cs | 10 -- EssentialCSharp.Web/Program.cs | 8 +- .../Services/IRouteConfigurationService.cs | 3 - 4 files changed, 57 insertions(+), 98 deletions(-) diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index 94b4bca0..6c801d1e 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -76,17 +76,13 @@ public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes() var baseUrl = "https://test.example.com/"; // Act & Assert - var nodes = _Factory.InServiceScope(serviceProvider => - { - var routeConfigurationService = serviceProvider.GetRequiredService(); - SitemapXmlHelpers.GenerateSitemapXml( - tempDir, - siteMappings, - routeConfigurationService, - baseUrl, - out var nodes); - return nodes; - }); + var routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); var allUrls = nodes.Select(n => n.Url).ToList(); @@ -108,17 +104,13 @@ public void GenerateSitemapXml_IncludesBaseUrl() var baseUrl = "https://test.example.com/"; // Act & Assert - var nodes = _Factory.InServiceScope(serviceProvider => - { - var routeConfigurationService = serviceProvider.GetRequiredService(); - SitemapXmlHelpers.GenerateSitemapXml( - tempDir, - siteMappings, - routeConfigurationService, - baseUrl, - out var nodes); - return nodes; - }); + var routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); Assert.Contains(nodes, node => node.Url == baseUrl); @@ -143,22 +135,19 @@ public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() }; // Act & Assert - _Factory.InServiceScope(serviceProvider => - { - var routeConfigurationService = serviceProvider.GetRequiredService(); - SitemapXmlHelpers.GenerateSitemapXml( - tempDir, - siteMappings, - routeConfigurationService, - baseUrl, - out var nodes); + var routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); - var allUrls = nodes.Select(n => n.Url).ToList(); + var allUrls = nodes.Select(n => n.Url).ToList(); - Assert.Contains(allUrls, url => url.Contains("test-page-1")); - Assert.DoesNotContain(allUrls, url => url.Contains("test-page-2")); // Not marked for XML - Assert.Contains(allUrls, url => url.Contains("test-page-3")); - }); + Assert.Contains(allUrls, url => url.Contains("test-page-1")); + Assert.DoesNotContain(allUrls, url => url.Contains("test-page-2")); // Not marked for XML + Assert.Contains(allUrls, url => url.Contains("test-page-3")); } [Fact] @@ -170,21 +159,18 @@ public void GenerateSitemapXml_DoesNotIncludeIndexRoutes() var baseUrl = "https://test.example.com/"; // Act & Assert - _Factory.InServiceScope(serviceProvider => - { - var routeConfigurationService = serviceProvider.GetRequiredService(); - SitemapXmlHelpers.GenerateSitemapXml( - tempDir, - siteMappings, - routeConfigurationService, - baseUrl, - out var nodes); + var routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); - var allUrls = nodes.Select(n => n.Url).ToList(); + var allUrls = nodes.Select(n => n.Url).ToList(); - // Should not include Index action routes (they're the default) - Assert.DoesNotContain(allUrls, url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase)); - }); + // Should not include Index action routes (they're the default) + Assert.DoesNotContain(allUrls, url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -196,21 +182,18 @@ public void GenerateSitemapXml_DoesNotIncludeErrorRoutes() var baseUrl = "https://test.example.com/"; // Act & Assert - _Factory.InServiceScope(serviceProvider => - { - var routeConfigurationService = serviceProvider.GetRequiredService(); - SitemapXmlHelpers.GenerateSitemapXml( - tempDir, - siteMappings, - routeConfigurationService, - baseUrl, - out var nodes); + var routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var nodes); - var allUrls = nodes.Select(n => n.Url).ToList(); + var allUrls = nodes.Select(n => n.Url).ToList(); - // Should not include Error action routes - Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase)); - }); + // Should not include Error action routes + Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -224,24 +207,18 @@ public void GenerateAndSerializeSitemapXml_CreatesFileSuccessfully() // Clean up any existing file var expectedXmlPath = Path.Combine(tempDir.FullName, "sitemap.xml"); - if (File.Exists(expectedXmlPath)) - { - File.Delete(expectedXmlPath); - } + File.Delete(expectedXmlPath); try { // Act - _Factory.InServiceScope(serviceProvider => - { - var routeConfigurationService = serviceProvider.GetRequiredService(); - SitemapXmlHelpers.GenerateAndSerializeSitemapXml( - tempDir, - siteMappings, - logger, - routeConfigurationService, - baseUrl); - }); + var routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateAndSerializeSitemapXml( + tempDir, + siteMappings, + logger, + routeConfigurationService, + baseUrl); // Assert Assert.True(File.Exists(expectedXmlPath)); @@ -254,10 +231,7 @@ public void GenerateAndSerializeSitemapXml_CreatesFileSuccessfully() finally { // Clean up - if (File.Exists(expectedXmlPath)) - { - File.Delete(expectedXmlPath); - } + File.Delete(expectedXmlPath); } } diff --git a/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs index 380206b9..0247b6f5 100644 --- a/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs +++ b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs @@ -98,13 +98,3 @@ private static decimal GetPriorityForRoute(string route) }; } } - -public class InvalidItemException : Exception -{ - public InvalidItemException(string? message) : base(message) - { - } - public InvalidItemException(string? message, Exception exception) : base(message, exception) - { - } -} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 3ae9be23..f0f807b6 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -11,7 +11,6 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.EntityFrameworkCore; namespace EssentialCSharp.Web; @@ -148,7 +147,7 @@ private static void Main(string[] args) builder.Services.AddRazorPages(); builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); @@ -228,9 +227,8 @@ private static void Main(string[] args) try { // Create a scope to resolve scoped services - using var scope = app.Services.CreateScope(); - var routeConfigurationService = scope.ServiceProvider.GetRequiredService(); - + var routeConfigurationService = app.Services.GetRequiredService(); + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); SitemapXmlHelpers.GenerateAndSerializeSitemapXml(wwwrootDirectory, siteMappingService.SiteMappings.ToList(), logger, routeConfigurationService, baseUrl); logger.LogInformation("Sitemap.xml generation completed successfully during application startup"); diff --git a/EssentialCSharp.Web/Services/IRouteConfigurationService.cs b/EssentialCSharp.Web/Services/IRouteConfigurationService.cs index 10258ccb..b22b1f0a 100644 --- a/EssentialCSharp.Web/Services/IRouteConfigurationService.cs +++ b/EssentialCSharp.Web/Services/IRouteConfigurationService.cs @@ -1,6 +1,3 @@ -using Microsoft.AspNetCore.Mvc; -using System.Reflection; - namespace EssentialCSharp.Web.Services; public interface IRouteConfigurationService