Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -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);
}
}
Loading