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..9280e3fd --- /dev/null +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -0,0 +1,35 @@ +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Tests; + +public class RouteConfigurationServiceTests : IClassFixture +{ + private readonly WebApplicationFactory _Factory; + + public RouteConfigurationServiceTests(WebApplicationFactory factory) + { + _Factory = factory; + } + + [Fact] + public void GetStaticRoutes_ShouldReturnExpectedRoutes() + { + // Act + var routes = _Factory.InServiceScope(serviceProvider => + { + var routeConfigurationService = serviceProvider.GetRequiredService(); + return 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); + } +} diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs new file mode 100644 index 00000000..6c801d1e --- /dev/null +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -0,0 +1,259 @@ +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 routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var 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 routeConfigurationService = _Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + tempDir, + siteMappings, + routeConfigurationService, + baseUrl, + out var 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 + var routeConfigurationService = _Factory.Services.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 + var routeConfigurationService = _Factory.Services.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 + var routeConfigurationService = _Factory.Services.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"); + File.Delete(expectedXmlPath); + + try + { + // Act + var routeConfigurationService = _Factory.Services.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 { private static string SqlConnectionString => $"DataSource=file:{Guid.NewGuid()}?mode=memory&cache=shared"; private SqliteConnection? _Connection; @@ -42,6 +42,30 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } + /// + /// 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); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/EssentialCSharp.Web/Controllers/BaseController.cs b/EssentialCSharp.Web/Controllers/BaseController.cs new file mode 100644 index 00000000..f1bdbb89 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/BaseController.cs @@ -0,0 +1,22 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +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..0247b6f5 --- /dev/null +++ b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs @@ -0,0 +1,100 @@ +using DotnetSitemapGenerator; +using DotnetSitemapGenerator.Serialization; +using EssentialCSharp.Web.Services; + +namespace EssentialCSharp.Web.Helpers; + +public static class SitemapXmlHelpers +{ + public static void EnsureSitemapHealthy(List siteMappings) + { + var groups = siteMappings.GroupBy(item => new { item.ChapterNumber, item.PageNumber }); + foreach (var group in groups) + { + 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"); + } + } + } + + public static void GenerateAndSerializeSitemapXml(DirectoryInfo wwwrootDirectory, List siteMappings, ILogger logger, IRouteConfigurationService routeConfigurationService, string baseUrl) + { + 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, IRouteConfigurationService routeConfigurationService, string baseUrl, out List nodes) + { + DateTime newDateTime = DateTime.UtcNow; + + // Routes should end up with leading slash + baseUrl = baseUrl.TrimEnd('/'); + + // Start with the root URL + nodes = new() { + new($"{baseUrl}/") + { + LastModificationDate = newDateTime, + ChangeFrequency = ChangeFrequency.Daily, + Priority = 1.0M + } + }; + + // Add routes dynamically discovered from controllers + var allRoutes = routeConfigurationService.GetStaticRoutes(); + var controllerRoutes = allRoutes + .Where(route => !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($"{baseUrl}{route}") + { + LastModificationDate = newDateTime, + ChangeFrequency = GetChangeFrequencyForRoute(route), + Priority = GetPriorityForRoute(route) + }); + } + + // Add site mappings from content + nodes.AddRange(siteMappings.Where(item => item.IncludeInSitemapXml).Select(siteMapping => new($"{baseUrl.TrimEnd('/')}/{siteMapping.Keys.First()}") + { + LastModificationDate = newDateTime, + ChangeFrequency = ChangeFrequency.Daily, + Priority = 0.8M + })); + } + + 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 + }; + } +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 56f97373..f0f807b6 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; @@ -41,7 +42,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()) { @@ -51,17 +52,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."); } } @@ -146,6 +147,7 @@ private static void Main(string[] args) builder.Services.AddRazorPages(); builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); @@ -182,7 +184,6 @@ private static void Main(string[] args) WebApplication app = builder.Build(); - // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -215,6 +216,29 @@ 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 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 + 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"); + } + 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..b22b1f0a --- /dev/null +++ b/EssentialCSharp.Web/Services/IRouteConfigurationService.cs @@ -0,0 +1,6 @@ +namespace EssentialCSharp.Web.Services; + +public interface IRouteConfigurationService +{ + 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