Skip to content

Commit a652da9

Browse files
Improves sitemap generation and route handling.
Refactors sitemap XML generation to use a dedicated service for route configuration. This change improves the sitemap generation process by: - Introducing `IRouteConfigurationService` to centralize route retrieval and filtering logic. - Excluding Identity, Index, and Error routes from the sitemap. - Adding tests to validate sitemap generation. - Configuring the base URL for sitemap generation through configuration. - Ensuring sitemap health by validating canonical links. This approach provides better control over the routes included in the sitemap and enhances the overall maintainability and testability of the sitemap generation process.
1 parent 9e398c3 commit a652da9

File tree

10 files changed

+420
-155
lines changed

10 files changed

+420
-155
lines changed
Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,26 @@
11
using EssentialCSharp.Web.Services;
2-
using Microsoft.AspNetCore.Mvc.Testing;
32
using Microsoft.Extensions.DependencyInjection;
43

54
namespace EssentialCSharp.Web.Tests;
65

76
public class RouteConfigurationServiceTests : IClassFixture<WebApplicationFactory>
87
{
98
private readonly WebApplicationFactory _Factory;
10-
private readonly IRouteConfigurationService _RouteConfigurationService;
119

12-
internal RouteConfigurationServiceTests(WebApplicationFactory factory)
10+
public RouteConfigurationServiceTests(WebApplicationFactory factory)
1311
{
1412
_Factory = factory;
15-
16-
// Get the service from the DI container to test with real routes
17-
var scope = _Factory.Services.CreateScope();
18-
_RouteConfigurationService = scope.ServiceProvider.GetRequiredService<IRouteConfigurationService>();
1913
}
2014

2115
[Fact]
2216
public void GetStaticRoutes_ShouldReturnExpectedRoutes()
2317
{
2418
// Act
25-
var routes = _RouteConfigurationService.GetStaticRoutes().ToList();
19+
var routes = _Factory.InServiceScope(serviceProvider =>
20+
{
21+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
22+
return routeConfigurationService.GetStaticRoutes().ToList();
23+
});
2624

2725
// Assert
2826
Assert.NotEmpty(routes);
@@ -34,32 +32,4 @@ public void GetStaticRoutes_ShouldReturnExpectedRoutes()
3432
Assert.Contains("announcements", routes);
3533
Assert.Contains("termsofservice", routes);
3634
}
37-
38-
[Fact]
39-
public void GetStaticRoutes_ShouldIncludeAllHomeControllerRoutes()
40-
{
41-
// Act
42-
var routes = _RouteConfigurationService.GetStaticRoutes().ToHashSet(StringComparer.OrdinalIgnoreCase);
43-
44-
// Assert - check all expected routes from HomeController
45-
var expectedRoutes = new[] { "home", "about", "guidelines", "announcements", "termsofservice" };
46-
47-
foreach (var expectedRoute in expectedRoutes)
48-
{
49-
Assert.True(routes.Contains(expectedRoute),
50-
$"Expected route '{expectedRoute}' was not found in discovered routes: [{string.Join(", ", routes)}]");
51-
}
52-
}
53-
54-
[Fact]
55-
public void GetStaticRoutes_ShouldNotIncludeIdentityRoutes()
56-
{
57-
// Act
58-
var routes = _RouteConfigurationService.GetStaticRoutes();
59-
60-
// Assert - ensure no Identity area routes are included
61-
Assert.DoesNotContain("identity", routes, StringComparer.OrdinalIgnoreCase);
62-
}
63-
64-
6535
}
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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 nodes = _Factory.InServiceScope(serviceProvider =>
80+
{
81+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
82+
SitemapXmlHelpers.GenerateSitemapXml(
83+
tempDir,
84+
siteMappings,
85+
routeConfigurationService,
86+
baseUrl,
87+
out var nodes);
88+
return nodes;
89+
});
90+
91+
var allUrls = nodes.Select(n => n.Url).ToList();
92+
93+
// Verify no Identity routes are included
94+
Assert.DoesNotContain(allUrls, url => url.Contains("Identity", StringComparison.OrdinalIgnoreCase));
95+
Assert.DoesNotContain(allUrls, url => url.Contains("Account", StringComparison.OrdinalIgnoreCase));
96+
97+
// But verify that expected routes are included
98+
Assert.Contains(allUrls, url => url.Contains("/home", StringComparison.OrdinalIgnoreCase));
99+
Assert.Contains(allUrls, url => url.Contains("/about", StringComparison.OrdinalIgnoreCase));
100+
}
101+
102+
[Fact]
103+
public void GenerateSitemapXml_IncludesBaseUrl()
104+
{
105+
// Arrange
106+
var tempDir = new DirectoryInfo(Path.GetTempPath());
107+
var siteMappings = new List<SiteMapping>();
108+
var baseUrl = "https://test.example.com/";
109+
110+
// Act & Assert
111+
var nodes = _Factory.InServiceScope(serviceProvider =>
112+
{
113+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
114+
SitemapXmlHelpers.GenerateSitemapXml(
115+
tempDir,
116+
siteMappings,
117+
routeConfigurationService,
118+
baseUrl,
119+
out var nodes);
120+
return nodes;
121+
});
122+
123+
Assert.Contains(nodes, node => node.Url == baseUrl);
124+
125+
// Verify the root URL has highest priority
126+
var rootNode = nodes.First(node => node.Url == baseUrl);
127+
Assert.Equal(1.0M, rootNode.Priority);
128+
Assert.Equal(ChangeFrequency.Daily, rootNode.ChangeFrequency);
129+
}
130+
131+
[Fact]
132+
public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml()
133+
{
134+
// Arrange
135+
var tempDir = new DirectoryInfo(Path.GetTempPath());
136+
var baseUrl = "https://test.example.com/";
137+
138+
var siteMappings = new List<SiteMapping>
139+
{
140+
CreateSiteMapping(1, 1, true, "test-page-1"),
141+
CreateSiteMapping(1, 2, false, "test-page-2"), // Not included in XML
142+
CreateSiteMapping(2, 1, true, "test-page-3")
143+
};
144+
145+
// Act & Assert
146+
_Factory.InServiceScope(serviceProvider =>
147+
{
148+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
149+
SitemapXmlHelpers.GenerateSitemapXml(
150+
tempDir,
151+
siteMappings,
152+
routeConfigurationService,
153+
baseUrl,
154+
out var nodes);
155+
156+
var allUrls = nodes.Select(n => n.Url).ToList();
157+
158+
Assert.Contains(allUrls, url => url.Contains("test-page-1"));
159+
Assert.DoesNotContain(allUrls, url => url.Contains("test-page-2")); // Not marked for XML
160+
Assert.Contains(allUrls, url => url.Contains("test-page-3"));
161+
});
162+
}
163+
164+
[Fact]
165+
public void GenerateSitemapXml_DoesNotIncludeIndexRoutes()
166+
{
167+
// Arrange
168+
var tempDir = new DirectoryInfo(Path.GetTempPath());
169+
var siteMappings = new List<SiteMapping>();
170+
var baseUrl = "https://test.example.com/";
171+
172+
// Act & Assert
173+
_Factory.InServiceScope(serviceProvider =>
174+
{
175+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
176+
SitemapXmlHelpers.GenerateSitemapXml(
177+
tempDir,
178+
siteMappings,
179+
routeConfigurationService,
180+
baseUrl,
181+
out var nodes);
182+
183+
var allUrls = nodes.Select(n => n.Url).ToList();
184+
185+
// Should not include Index action routes (they're the default)
186+
Assert.DoesNotContain(allUrls, url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase));
187+
});
188+
}
189+
190+
[Fact]
191+
public void GenerateSitemapXml_DoesNotIncludeErrorRoutes()
192+
{
193+
// Arrange
194+
var tempDir = new DirectoryInfo(Path.GetTempPath());
195+
var siteMappings = new List<SiteMapping>();
196+
var baseUrl = "https://test.example.com/";
197+
198+
// Act & Assert
199+
_Factory.InServiceScope(serviceProvider =>
200+
{
201+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
202+
SitemapXmlHelpers.GenerateSitemapXml(
203+
tempDir,
204+
siteMappings,
205+
routeConfigurationService,
206+
baseUrl,
207+
out var nodes);
208+
209+
var allUrls = nodes.Select(n => n.Url).ToList();
210+
211+
// Should not include Error action routes
212+
Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase));
213+
});
214+
}
215+
216+
[Fact]
217+
public void GenerateAndSerializeSitemapXml_CreatesFileSuccessfully()
218+
{
219+
// Arrange
220+
var logger = _Factory.Services.GetRequiredService<ILogger<SitemapXmlHelpersTests>>();
221+
var tempDir = new DirectoryInfo(Path.GetTempPath());
222+
var siteMappings = new List<SiteMapping> { CreateSiteMapping(1, 1, true) };
223+
var baseUrl = "https://test.example.com/";
224+
225+
// Clean up any existing file
226+
var expectedXmlPath = Path.Combine(tempDir.FullName, "sitemap.xml");
227+
if (File.Exists(expectedXmlPath))
228+
{
229+
File.Delete(expectedXmlPath);
230+
}
231+
232+
try
233+
{
234+
// Act
235+
_Factory.InServiceScope(serviceProvider =>
236+
{
237+
var routeConfigurationService = serviceProvider.GetRequiredService<IRouteConfigurationService>();
238+
SitemapXmlHelpers.GenerateAndSerializeSitemapXml(
239+
tempDir,
240+
siteMappings,
241+
logger,
242+
routeConfigurationService,
243+
baseUrl);
244+
});
245+
246+
// Assert
247+
Assert.True(File.Exists(expectedXmlPath));
248+
249+
var xmlContent = File.ReadAllText(expectedXmlPath);
250+
Assert.Contains("<?xml", xmlContent);
251+
Assert.Contains("<urlset", xmlContent);
252+
Assert.Contains(baseUrl, xmlContent);
253+
}
254+
finally
255+
{
256+
// Clean up
257+
if (File.Exists(expectedXmlPath))
258+
{
259+
File.Delete(expectedXmlPath);
260+
}
261+
}
262+
}
263+
264+
private static SiteMapping CreateSiteMapping(
265+
int chapterNumber,
266+
int pageNumber,
267+
bool includeInSitemapXml,
268+
string key = "test-key")
269+
{
270+
return new SiteMapping(
271+
keys: [key],
272+
primaryKey: key,
273+
pagePath: ["Chapters", chapterNumber.ToString("00", CultureInfo.InvariantCulture), "Pages", $"{pageNumber:00}.html"],
274+
chapterNumber: chapterNumber,
275+
pageNumber: pageNumber,
276+
orderOnPage: 0,
277+
chapterTitle: $"Chapter {chapterNumber}",
278+
rawHeading: "Test Heading",
279+
anchorId: key,
280+
indentLevel: 1,
281+
contentHash: "TestHash123",
282+
includeInSitemapXml: includeInSitemapXml
283+
);
284+
}
285+
}

EssentialCSharp.Web.Tests/WebApplicationFactory.cs

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

77
namespace EssentialCSharp.Web.Tests;
88

9-
internal sealed class WebApplicationFactory : WebApplicationFactory<Program>
9+
public sealed class WebApplicationFactory : WebApplicationFactory<Program>
1010
{
1111
protected override void ConfigureWebHost(IWebHostBuilder builder)
1212
{
@@ -36,4 +36,28 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
3636
db.Database.EnsureCreated();
3737
});
3838
}
39+
40+
/// <summary>
41+
/// Executes an action within a service scope, handling scope creation and cleanup automatically.
42+
/// </summary>
43+
/// <typeparam name="T">The return type of the action</typeparam>
44+
/// <param name="action">The action to execute with the scoped service provider</param>
45+
/// <returns>The result of the action</returns>
46+
public T InServiceScope<T>(Func<IServiceProvider, T> action)
47+
{
48+
var factory = Services.GetRequiredService<IServiceScopeFactory>();
49+
using var scope = factory.CreateScope();
50+
return action(scope.ServiceProvider);
51+
}
52+
53+
/// <summary>
54+
/// Executes an action within a service scope, handling scope creation and cleanup automatically.
55+
/// </summary>
56+
/// <param name="action">The action to execute with the scoped service provider</param>
57+
public void InServiceScope(Action<IServiceProvider> action)
58+
{
59+
var factory = Services.GetRequiredService<IServiceScopeFactory>();
60+
using var scope = factory.CreateScope();
61+
action(scope.ServiceProvider);
62+
}
3963
}

0 commit comments

Comments
 (0)