Skip to content

Commit fa882bb

Browse files
Merge branch 'main' into AIChat
2 parents faa5833 + fab8f9b commit fa882bb

15 files changed

+581
-6
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta6.25358.103" />
4848
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
4949
<PackageVersion Include="Octokit" Version="14.0.0" />
50+
<PackageVersion Include="DotnetSitemapGenerator" Version="1.0.4" />
5051
<PackageVersion Include="xunit" Version="2.9.3" />
5152
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.1" />
5253
</ItemGroup>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using EssentialCSharp.Web.Services;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace EssentialCSharp.Web.Tests;
5+
6+
public class RouteConfigurationServiceTests : IClassFixture<WebApplicationFactory>
7+
{
8+
private readonly WebApplicationFactory _Factory;
9+
10+
public RouteConfigurationServiceTests(WebApplicationFactory factory)
11+
{
12+
_Factory = factory;
13+
}
14+
15+
[Fact]
16+
public void GetStaticRoutes_ShouldReturnExpectedRoutes()
17+
{
18+
// Act
19+
var routes = _Factory.InServiceScope(serviceProvider =>
20+
{
21+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
22+
return routeConfigurationService.GetStaticRoutes().ToList();
23+
});
24+
25+
// Assert
26+
Assert.NotEmpty(routes);
27+
28+
// Check for expected routes from the HomeController
29+
Assert.Contains("home", routes);
30+
Assert.Contains("about", routes);
31+
Assert.Contains("guidelines", routes);
32+
Assert.Contains("announcements", routes);
33+
Assert.Contains("termsofservice", routes);
34+
}
35+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
using System.Globalization;
2+
using DotnetSitemapGenerator;
3+
using EssentialCSharp.Web.Helpers;
4+
using EssentialCSharp.Web.Services;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace EssentialCSharp.Web.Tests;
9+
10+
public class SitemapXmlHelpersTests : IClassFixture<WebApplicationFactory>
11+
{
12+
private readonly WebApplicationFactory _Factory;
13+
14+
public SitemapXmlHelpersTests(WebApplicationFactory factory)
15+
{
16+
_Factory = factory;
17+
}
18+
19+
[Fact]
20+
public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow()
21+
{
22+
// Arrange
23+
var siteMappings = new List<SiteMapping>
24+
{
25+
CreateSiteMapping(1, 1, true),
26+
CreateSiteMapping(1, 2, true),
27+
CreateSiteMapping(2, 1, true)
28+
};
29+
30+
// Act & Assert
31+
var exception = Record.Exception(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings));
32+
Assert.Null(exception);
33+
}
34+
35+
[Fact]
36+
public void EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsException()
37+
{
38+
// Arrange - Two mappings for the same chapter/page both marked as canonical
39+
var siteMappings = new List<SiteMapping>
40+
{
41+
CreateSiteMapping(1, 1, true),
42+
CreateSiteMapping(1, 1, true) // Same chapter/page, also canonical
43+
};
44+
45+
// Act & Assert
46+
var exception = Assert.Throws<InvalidOperationException>(() =>
47+
SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings));
48+
49+
Assert.Contains("Chapter 1, Page 1", exception.Message);
50+
Assert.Contains("more than one canonical link", exception.Message);
51+
}
52+
53+
[Fact]
54+
public void EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException()
55+
{
56+
// Arrange - No mappings marked as canonical for this page
57+
var siteMappings = new List<SiteMapping>
58+
{
59+
CreateSiteMapping(1, 1, false),
60+
CreateSiteMapping(1, 1, false) // Same chapter/page, neither canonical
61+
};
62+
63+
// Act & Assert
64+
var exception = Assert.Throws<InvalidOperationException>(() =>
65+
SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings));
66+
67+
Assert.Contains("Chapter 1, Page 1", exception.Message);
68+
}
69+
70+
[Fact]
71+
public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes()
72+
{
73+
// Arrange
74+
var tempDir = new DirectoryInfo(Path.GetTempPath());
75+
var siteMappings = new List<SiteMapping> { CreateSiteMapping(1, 1, true) };
76+
var baseUrl = "https://test.example.com/";
77+
78+
// Act & Assert
79+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
80+
SitemapXmlHelpers.GenerateSitemapXml(
81+
tempDir,
82+
siteMappings,
83+
routeConfigurationService,
84+
baseUrl,
85+
out var nodes);
86+
87+
var allUrls = nodes.Select(n => n.Url).ToList();
88+
89+
// Verify no Identity routes are included
90+
Assert.DoesNotContain(allUrls, url => url.Contains("Identity", StringComparison.OrdinalIgnoreCase));
91+
Assert.DoesNotContain(allUrls, url => url.Contains("Account", StringComparison.OrdinalIgnoreCase));
92+
93+
// But verify that expected routes are included
94+
Assert.Contains(allUrls, url => url.Contains("/home", StringComparison.OrdinalIgnoreCase));
95+
Assert.Contains(allUrls, url => url.Contains("/about", StringComparison.OrdinalIgnoreCase));
96+
}
97+
98+
[Fact]
99+
public void GenerateSitemapXml_IncludesBaseUrl()
100+
{
101+
// Arrange
102+
var tempDir = new DirectoryInfo(Path.GetTempPath());
103+
var siteMappings = new List<SiteMapping>();
104+
var baseUrl = "https://test.example.com/";
105+
106+
// Act & Assert
107+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
108+
SitemapXmlHelpers.GenerateSitemapXml(
109+
tempDir,
110+
siteMappings,
111+
routeConfigurationService,
112+
baseUrl,
113+
out var nodes);
114+
115+
Assert.Contains(nodes, node => node.Url == baseUrl);
116+
117+
// Verify the root URL has highest priority
118+
var rootNode = nodes.First(node => node.Url == baseUrl);
119+
Assert.Equal(1.0M, rootNode.Priority);
120+
Assert.Equal(ChangeFrequency.Daily, rootNode.ChangeFrequency);
121+
}
122+
123+
[Fact]
124+
public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml()
125+
{
126+
// Arrange
127+
var tempDir = new DirectoryInfo(Path.GetTempPath());
128+
var baseUrl = "https://test.example.com/";
129+
130+
var siteMappings = new List<SiteMapping>
131+
{
132+
CreateSiteMapping(1, 1, true, "test-page-1"),
133+
CreateSiteMapping(1, 2, false, "test-page-2"), // Not included in XML
134+
CreateSiteMapping(2, 1, true, "test-page-3")
135+
};
136+
137+
// Act & Assert
138+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
139+
SitemapXmlHelpers.GenerateSitemapXml(
140+
tempDir,
141+
siteMappings,
142+
routeConfigurationService,
143+
baseUrl,
144+
out var nodes);
145+
146+
var allUrls = nodes.Select(n => n.Url).ToList();
147+
148+
Assert.Contains(allUrls, url => url.Contains("test-page-1"));
149+
Assert.DoesNotContain(allUrls, url => url.Contains("test-page-2")); // Not marked for XML
150+
Assert.Contains(allUrls, url => url.Contains("test-page-3"));
151+
}
152+
153+
[Fact]
154+
public void GenerateSitemapXml_DoesNotIncludeIndexRoutes()
155+
{
156+
// Arrange
157+
var tempDir = new DirectoryInfo(Path.GetTempPath());
158+
var siteMappings = new List<SiteMapping>();
159+
var baseUrl = "https://test.example.com/";
160+
161+
// Act & Assert
162+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
163+
SitemapXmlHelpers.GenerateSitemapXml(
164+
tempDir,
165+
siteMappings,
166+
routeConfigurationService,
167+
baseUrl,
168+
out var nodes);
169+
170+
var allUrls = nodes.Select(n => n.Url).ToList();
171+
172+
// Should not include Index action routes (they're the default)
173+
Assert.DoesNotContain(allUrls, url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase));
174+
}
175+
176+
[Fact]
177+
public void GenerateSitemapXml_DoesNotIncludeErrorRoutes()
178+
{
179+
// Arrange
180+
var tempDir = new DirectoryInfo(Path.GetTempPath());
181+
var siteMappings = new List<SiteMapping>();
182+
var baseUrl = "https://test.example.com/";
183+
184+
// Act & Assert
185+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
186+
SitemapXmlHelpers.GenerateSitemapXml(
187+
tempDir,
188+
siteMappings,
189+
routeConfigurationService,
190+
baseUrl,
191+
out var nodes);
192+
193+
var allUrls = nodes.Select(n => n.Url).ToList();
194+
195+
// Should not include Error action routes
196+
Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase));
197+
}
198+
199+
[Fact]
200+
public void GenerateAndSerializeSitemapXml_CreatesFileSuccessfully()
201+
{
202+
// Arrange
203+
var logger = _Factory.Services.GetRequiredService<ILogger<SitemapXmlHelpersTests>>();
204+
var tempDir = new DirectoryInfo(Path.GetTempPath());
205+
var siteMappings = new List<SiteMapping> { CreateSiteMapping(1, 1, true) };
206+
var baseUrl = "https://test.example.com/";
207+
208+
// Clean up any existing file
209+
var expectedXmlPath = Path.Combine(tempDir.FullName, "sitemap.xml");
210+
File.Delete(expectedXmlPath);
211+
212+
try
213+
{
214+
// Act
215+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
216+
SitemapXmlHelpers.GenerateAndSerializeSitemapXml(
217+
tempDir,
218+
siteMappings,
219+
logger,
220+
routeConfigurationService,
221+
baseUrl);
222+
223+
// Assert
224+
Assert.True(File.Exists(expectedXmlPath));
225+
226+
var xmlContent = File.ReadAllText(expectedXmlPath);
227+
Assert.Contains("<?xml", xmlContent);
228+
Assert.Contains("<urlset", xmlContent);
229+
Assert.Contains(baseUrl, xmlContent);
230+
}
231+
finally
232+
{
233+
// Clean up
234+
File.Delete(expectedXmlPath);
235+
}
236+
}
237+
238+
private static SiteMapping CreateSiteMapping(
239+
int chapterNumber,
240+
int pageNumber,
241+
bool includeInSitemapXml,
242+
string key = "test-key")
243+
{
244+
return new SiteMapping(
245+
keys: [key],
246+
primaryKey: key,
247+
pagePath: ["Chapters", chapterNumber.ToString("00", CultureInfo.InvariantCulture), "Pages", $"{pageNumber:00}.html"],
248+
chapterNumber: chapterNumber,
249+
pageNumber: pageNumber,
250+
orderOnPage: 0,
251+
chapterTitle: $"Chapter {chapterNumber}",
252+
rawHeading: "Test Heading",
253+
anchorId: key,
254+
indentLevel: 1,
255+
contentHash: "TestHash123",
256+
includeInSitemapXml: includeInSitemapXml
257+
);
258+
}
259+
}

EssentialCSharp.Web.Tests/WebApplicationFactory.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
namespace EssentialCSharp.Web.Tests;
99

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

45+
/// <summary>
46+
/// Executes an action within a service scope, handling scope creation and cleanup automatically.
47+
/// </summary>
48+
/// <typeparam name="T">The return type of the action</typeparam>
49+
/// <param name="action">The action to execute with the scoped service provider</param>
50+
/// <returns>The result of the action</returns>
51+
public T InServiceScope<T>(Func<IServiceProvider, T> action)
52+
{
53+
var factory = Services.GetRequiredService<IServiceScopeFactory>();
54+
using var scope = factory.CreateScope();
55+
return action(scope.ServiceProvider);
56+
}
57+
58+
/// <summary>
59+
/// Executes an action within a service scope, handling scope creation and cleanup automatically.
60+
/// </summary>
61+
/// <param name="action">The action to execute with the scoped service provider</param>
62+
public void InServiceScope(Action<IServiceProvider> action)
63+
{
64+
var factory = Services.GetRequiredService<IServiceScopeFactory>();
65+
using var scope = factory.CreateScope();
66+
action(scope.ServiceProvider);
67+
}
68+
4569
protected override void Dispose(bool disposing)
4670
{
4771
base.Dispose(disposing);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using EssentialCSharp.Web.Services;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.AspNetCore.Mvc.Filters;
4+
5+
namespace EssentialCSharp.Web.Controllers;
6+
7+
public abstract class BaseController : Controller
8+
{
9+
private readonly IRouteConfigurationService _RouteConfigurationService;
10+
11+
protected BaseController(IRouteConfigurationService routeConfigurationService)
12+
{
13+
_RouteConfigurationService = routeConfigurationService;
14+
}
15+
16+
public override void OnActionExecuting(ActionExecutingContext context)
17+
{
18+
// Automatically add static routes to all views
19+
ViewBag.StaticRoutes = System.Text.Json.JsonSerializer.Serialize(_RouteConfigurationService.GetStaticRoutes());
20+
base.OnActionExecuting(context);
21+
}
22+
}

EssentialCSharp.Web/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ WORKDIR /app
55
EXPOSE 8080
66
EXPOSE 8081
77

8-
FROM mcr.microsoft.com/dotnet/sdk:9.0.302 AS build
8+
FROM mcr.microsoft.com/dotnet/sdk:9.0.303 AS build
99
ARG ACCESS_TO_NUGET_FEED=true
1010
ENV ACCESS_TO_NUGET_FEED=$ACCESS_TO_NUGET_FEED
1111
RUN sh -c "$(curl -fsSL https://aka.ms/install-artifacts-credprovider.sh)"

EssentialCSharp.Web/EssentialCSharp.Web.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<PackageReference Include="Newtonsoft.Json" />
3636
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
3737
<PackageReference Include="Octokit" />
38+
<PackageReference Include="DotnetSitemapGenerator" />
3839
</ItemGroup>
3940
<ItemGroup>
4041
<Content Update="wwwroot\images\00mindmap.svg">

0 commit comments

Comments
 (0)