Skip to content
Merged
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);
}
}
259 changes: 259 additions & 0 deletions EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs
Original file line number Diff line number Diff line change
@@ -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<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 routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
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<SiteMapping>();
var baseUrl = "https://test.example.com/";

// Act & Assert
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
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<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
var routeConfigurationService = _Factory.Services.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
var routeConfigurationService = _Factory.Services.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
var routeConfigurationService = _Factory.Services.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");
File.Delete(expectedXmlPath);

try
{
// Act
var routeConfigurationService = _Factory.Services.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
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 @@ -7,7 +7,7 @@

namespace EssentialCSharp.Web.Tests;

internal sealed class WebApplicationFactory : WebApplicationFactory<Program>
public sealed class WebApplicationFactory : WebApplicationFactory<Program>
{
private static string SqlConnectionString => $"DataSource=file:{Guid.NewGuid()}?mode=memory&cache=shared";
private SqliteConnection? _Connection;
Expand Down Expand Up @@ -42,6 +42,30 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
});
}

/// <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);
}

protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Expand Down
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