Skip to content

feat: Adds sitemap generation #753

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="DotnetSitemapGenerator" Version="1.0.4" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
Expand Down
35 changes: 35 additions & 0 deletions EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using EssentialCSharp.Web.Services;
using Microsoft.Extensions.DependencyInjection;

namespace EssentialCSharp.Web.Tests;

public class RouteConfigurationServiceTests : IClassFixture<WebApplicationFactory>
{
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<IRouteConfigurationService>();
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);
}
}
285 changes: 285 additions & 0 deletions EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs
Original file line number Diff line number Diff line change
@@ -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<WebApplicationFactory>
{
private readonly WebApplicationFactory _Factory;

public SitemapXmlHelpersTests(WebApplicationFactory factory)
{
_Factory = factory;
}

[Fact]
public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow()
{
// Arrange
var siteMappings = new List<SiteMapping>
{
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<SiteMapping>
{
CreateSiteMapping(1, 1, true),
CreateSiteMapping(1, 1, true) // Same chapter/page, also canonical
};

// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
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<SiteMapping>
{
CreateSiteMapping(1, 1, false),
CreateSiteMapping(1, 1, false) // Same chapter/page, neither canonical
};

// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
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<SiteMapping> { CreateSiteMapping(1, 1, true) };
var baseUrl = "https://test.example.com/";

// Act & Assert
var nodes = _Factory.InServiceScope(serviceProvider =>
{
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
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<SiteMapping>();
var baseUrl = "https://test.example.com/";

// Act & Assert
var nodes = _Factory.InServiceScope(serviceProvider =>
{
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
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<SiteMapping>
{
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<IRouteConfigurationService>();
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<SiteMapping>();
var baseUrl = "https://test.example.com/";

// Act & Assert
_Factory.InServiceScope(serviceProvider =>
{
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
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<SiteMapping>();
var baseUrl = "https://test.example.com/";

// Act & Assert
_Factory.InServiceScope(serviceProvider =>
{
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
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<ILogger<SitemapXmlHelpersTests>>();
var tempDir = new DirectoryInfo(Path.GetTempPath());
var siteMappings = new List<SiteMapping> { 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<IRouteConfigurationService>();
SitemapXmlHelpers.GenerateAndSerializeSitemapXml(
tempDir,
siteMappings,
logger,
routeConfigurationService,
baseUrl);
});

// Assert
Assert.True(File.Exists(expectedXmlPath));

var xmlContent = File.ReadAllText(expectedXmlPath);
Assert.Contains("<?xml", xmlContent);
Assert.Contains("<urlset", xmlContent);
Assert.Contains(baseUrl, xmlContent);
}
finally
{
// Clean up
if (File.Exists(expectedXmlPath))
{
File.Delete(expectedXmlPath);
}
}
}

private static SiteMapping CreateSiteMapping(
int chapterNumber,
int pageNumber,
bool includeInSitemapXml,
string key = "test-key")
{
return new SiteMapping(
keys: [key],
primaryKey: key,
pagePath: ["Chapters", chapterNumber.ToString("00", CultureInfo.InvariantCulture), "Pages", $"{pageNumber:00}.html"],
chapterNumber: chapterNumber,
pageNumber: pageNumber,
orderOnPage: 0,
chapterTitle: $"Chapter {chapterNumber}",
rawHeading: "Test Heading",
anchorId: key,
indentLevel: 1,
contentHash: "TestHash123",
includeInSitemapXml: includeInSitemapXml
);
}
}
26 changes: 25 additions & 1 deletion EssentialCSharp.Web.Tests/WebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace EssentialCSharp.Web.Tests;

internal sealed class WebApplicationFactory : WebApplicationFactory<Program>
public sealed class WebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Expand Down Expand Up @@ -36,4 +36,28 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
db.Database.EnsureCreated();
});
}

/// <summary>
/// Executes an action within a service scope, handling scope creation and cleanup automatically.
/// </summary>
/// <typeparam name="T">The return type of the action</typeparam>
/// <param name="action">The action to execute with the scoped service provider</param>
/// <returns>The result of the action</returns>
public T InServiceScope<T>(Func<IServiceProvider, T> action)
{
var factory = Services.GetRequiredService<IServiceScopeFactory>();
using var scope = factory.CreateScope();
return action(scope.ServiceProvider);
}

/// <summary>
/// Executes an action within a service scope, handling scope creation and cleanup automatically.
/// </summary>
/// <param name="action">The action to execute with the scoped service provider</param>
public void InServiceScope(Action<IServiceProvider> action)
{
var factory = Services.GetRequiredService<IServiceScopeFactory>();
using var scope = factory.CreateScope();
action(scope.ServiceProvider);
}
}
22 changes: 22 additions & 0 deletions EssentialCSharp.Web/Controllers/BaseController.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions EssentialCSharp.Web/EssentialCSharp.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
<PackageReference Include="Octokit" />
<PackageReference Include="DotnetSitemapGenerator" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\images\00mindmap.svg">
Expand Down
Loading