diff --git a/.editorconfig b/.editorconfig index 8b6b66f0..90272961 100644 --- a/.editorconfig +++ b/.editorconfig @@ -156,4 +156,7 @@ csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent # CA1848: Use the LoggerMessage delegates -dotnet_diagnostic.CA1848.severity = suggestion \ No newline at end of file +dotnet_diagnostic.CA1848.severity = suggestion +# Test files - allow underscore-separated test method names (CA1707) +[{EssentialCSharp.Web.Tests,EssentialCSharp.Chat.Tests}/**] +dotnet_diagnostic.CA1707.severity = none diff --git a/.github/workflows/PR-Build-And-Test.yml b/.github/workflows/PR-Build-And-Test.yml index 1fd7a1d2..4117d976 100644 --- a/.github/workflows/PR-Build-And-Test.yml +++ b/.github/workflows/PR-Build-And-Test.yml @@ -35,7 +35,7 @@ jobs: run: dotnet build --configuration Release --no-restore /p:AccessToNugetFeed=false - name: Run .NET Tests - run: dotnet test --no-build --configuration Release --logger trx --results-directory ${{ runner.temp }} + run: dotnet test --no-build --configuration Release --report-trx --coverage --results-directory ${{ runner.temp }} - name: Convert TRX to VS Playlist if: failure() diff --git a/Directory.Packages.props b/Directory.Packages.props index cacbdee9..5ae6f88a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,7 +21,7 @@ - + @@ -36,7 +36,6 @@ - @@ -50,7 +49,5 @@ - - diff --git a/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj b/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj index 62dc7206..c8638c29 100644 --- a/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj +++ b/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj @@ -6,19 +6,13 @@ - - - - + - - - diff --git a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs index 8aab8cb6..e14ef47e 100644 --- a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs +++ b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs @@ -2,13 +2,12 @@ using Moq; namespace EssentialCSharp.Chat.Tests; -// TODO: Move to editorconfig later, just moving quick -#pragma warning disable CA1707 // Identifiers should not contain underscores + public class MarkdownChunkingServiceTests { #region MarkdownContentToHeadersAndSection - [Fact] - public void MarkdownContentToHeadersAndSection_ParsesSampleMarkdown_CorrectlyCombinesHeadersAndExtractsContent() + [Test] + public async Task MarkdownContentToHeadersAndSection_ParsesSampleMarkdown_CorrectlyCombinesHeadersAndExtractsContent() { string markdown = """ ### Beginner Topic @@ -43,15 +42,17 @@ publicstaticvoid Main() // Method declaration var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); - Assert.Equal(3, sections.Count); - Assert.Contains(sections, s => s.Header == "Beginner Topic: What Is a Method?" && string.Join("\n", s.Content).Contains("Syntactically, a **method** in C# is a named block of code")); - Assert.Contains(sections, s => s.Header == "Main Method" && string.Join("\n", s.Content).Contains("The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`") - && string.Join("\n", s.Content).Contains("publicclass Program")); - Assert.Contains(sections, s => s.Header == "Main Method: Advanced Topic: Declaration of the Main Method" && string.Join("\n", s.Content).Contains("C# requires that the Main method return either `void` or `int`")); + await Assert.That(sections.Count).IsEqualTo(3); + await Assert.That(sections).Contains(s => s.Header == "Beginner Topic: What Is a Method?" && string.Join("\n", s.Content).Contains("Syntactically, a **method** in C# is a named block of code")); + + await Assert.That(sections).Contains(s => s.Header == "Main Method" && string.Join("\n", s.Content).Contains("The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`")); + await Assert.That(sections).Contains(s => s.Header == "Main Method" && string.Join("\n", s.Content).Contains("publicclass Program")); + + await Assert.That(sections).Contains(s => s.Header == "Main Method: Advanced Topic: Declaration of the Main Method" && string.Join("\n", s.Content).Contains("C# requires that the Main method return either `void` or `int`")); } - [Fact] - public void MarkdownContentToHeadersAndSection_AppendsCodeListingToPriorSection() + [Test] + public async Task MarkdownContentToHeadersAndSection_AppendsCodeListingToPriorSection() { string markdown = """ ## Working with Variables @@ -86,16 +87,14 @@ publicstaticvoid Main() var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); - Assert.Equal(2, sections.Count); + await Assert.That(sections.Count).IsEqualTo(2); // The code listing should be appended to the Working with Variables section, not as its own section - var workingWithVariablesSection = sections.FirstOrDefault(s => s.Header == "Working with Variables"); - Assert.True(!string.IsNullOrEmpty(workingWithVariablesSection.Header)); - Assert.Contains("publicclass MiracleMax", string.Join("\n", workingWithVariablesSection.Content)); - Assert.DoesNotContain(sections, s => s.Header == "Listing 1.12: Declaring and Assigning a Variable"); + await Assert.That(sections).Contains(s => s.Header == "Working with Variables" && string.Join("\n", s.Content).Contains("publicclass MiracleMax")); + await Assert.That(sections).DoesNotContain(s => s.Header == "Listing 1.12: Declaring and Assigning a Variable"); } - [Fact] - public void MarkdownContentToHeadersAndSection_KeepsPriorHeadersAppended() + [Test] + public async Task MarkdownContentToHeadersAndSection_KeepsPriorHeadersAppended() { string markdown = """ ### Beginner Topic @@ -143,19 +142,23 @@ publicstaticvoid Main() """; var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown); - Assert.Equal(5, sections.Count); + await Assert.That(sections.Count).IsEqualTo(5); + + await Assert.That(sections).Contains(s => s.Header == "Beginner Topic: What Is a Data Type?" && string.Join("\n", s.Content).Contains("The type of data that a variable declaration specifies is called a **data type**")); - Assert.Contains(sections, s => s.Header == "Beginner Topic: What Is a Data Type?" && string.Join("\n", s.Content).Contains("The type of data that a variable declaration specifies is called a **data type**")); - Assert.Contains(sections, s => s.Header == "Declaring a Variable" && string.Join("\n", s.Content).Contains("In Listing 1.12, `string max` is a variable declaration")); - Assert.Contains(sections, s => s.Header == "Declaring a Variable: Declaring another thing" && string.Join("\n", s.Content).Contains("Because a multivariable declaration statement allows developers to provide the data type only once")); - Assert.Contains(sections, s => s.Header == "Assigning a Variable" && string.Join("\n", s.Content).Contains("After declaring a local variable, you must assign it a value before reading from it.")); - Assert.Contains(sections, s => s.Header == "Assigning a Variable: Continued Learning" && string.Join("\n", s.Content).Contains("From this listing, observe that it is possible to assign a variable as part of the variable declaration")); + await Assert.That(sections).Contains(s => s.Header == "Declaring a Variable" && string.Join("\n", s.Content).Contains("In Listing 1.12, `string max` is a variable declaration")); + + await Assert.That(sections).Contains(s => s.Header == "Declaring a Variable: Declaring another thing" && string.Join("\n", s.Content).Contains("Because a multivariable declaration statement allows developers to provide the data type only once")); + + await Assert.That(sections).Contains(s => s.Header == "Assigning a Variable" && string.Join("\n", s.Content).Contains("After declaring a local variable, you must assign it a value before reading from it.")); + + await Assert.That(sections).Contains(s => s.Header == "Assigning a Variable: Continued Learning" && string.Join("\n", s.Content).Contains("From this listing, observe that it is possible to assign a variable as part of the variable declaration")); } #endregion MarkdownContentToHeadersAndSection #region ProcessSingleMarkdownFile - [Fact] - public void ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders() + [Test] + public async Task ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders() { // Arrange var logger = new Mock>().Object; @@ -178,15 +181,12 @@ public void ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders() var result = service.ProcessSingleMarkdownFile(fileContent, fileName, filePath); // Assert - Assert.NotNull(result); - Assert.Equal(fileName, result.FileName); - Assert.Equal(filePath, result.FilePath); - Assert.Contains("This is the first section.", string.Join("\n", result.Chunks)); - Assert.Contains("Console.WriteLine(\"Hello World\");", string.Join("\n", result.Chunks)); - Assert.Contains("This is the second section.", string.Join("\n", result.Chunks)); - Assert.Contains(result.Chunks, c => c.Contains("This is the second section.")); + await Assert.That(result).IsNotNull(); + await Assert.That(result.FileName).IsEqualTo(fileName); + await Assert.That(result.FilePath).IsEqualTo(filePath); + await Assert.That(string.Join("\n", result.Chunks)).Contains("This is the first section."); + await Assert.That(string.Join("\n", result.Chunks)).Contains("Console.WriteLine(\"Hello World\");"); + await Assert.That(result.Chunks).Contains(c => c.Contains("This is the second section.")); } #endregion ProcessSingleMarkdownFile } - -#pragma warning restore CA1707 // Identifiers should not contain underscores diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj index 88e40aa3..50848f91 100644 --- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj +++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj @@ -5,27 +5,14 @@ false false - - $(NoWarn);CA1707 - + - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs index abdf499d..78edc27a 100644 --- a/EssentialCSharp.Web.Tests/FunctionalTests.cs +++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs @@ -2,57 +2,53 @@ namespace EssentialCSharp.Web.Tests; -public class FunctionalTests +[NotInParallel("FunctionalTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class FunctionalTests(WebApplicationFactory factory) { - [Theory] - [InlineData("/")] - [InlineData("/hello-world")] - [InlineData("/hello-world#hello-world")] - [InlineData("/guidelines")] - [InlineData("/healthz")] + [Test] + [Arguments("/")] + [Arguments("/hello-world")] + [Arguments("/hello-world#hello-world")] + [Arguments("/guidelines")] + [Arguments("/healthz")] public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl) { - using WebApplicationFactory factory = new(); - HttpClient client = factory.CreateClient(); using HttpResponseMessage response = await client.GetAsync(relativeUrl); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); } - [Theory] - [InlineData("/guidelines?rid=test-referral-id")] - [InlineData("/about?rid=abc123")] - [InlineData("/hello-world?rid=user-referral")] - [InlineData("/guidelines?rid=")] - [InlineData("/about?rid= ")] - [InlineData("/guidelines?foo=bar")] - [InlineData("/about?someOtherParam=value")] + [Test] + [Arguments("/guidelines?rid=test-referral-id")] + [Arguments("/about?rid=abc123")] + [Arguments("/hello-world?rid=user-referral")] + [Arguments("/guidelines?rid=")] + [Arguments("/about?rid= ")] + [Arguments("/guidelines?foo=bar")] + [Arguments("/about?someOtherParam=value")] public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl) { - using WebApplicationFactory factory = new(); - HttpClient client = factory.CreateClient(); using HttpResponseMessage response = await client.GetAsync(relativeUrl); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + // Ensure the response has content (not blank) string content = await response.Content.ReadAsStringAsync(); - Assert.NotEmpty(content); - + await Assert.That(content).IsNotEmpty(); + // Verify it's actually HTML content, not just whitespace - Assert.Contains(" -{ - [Fact] - public async Task CaptchaService_Verify_Success() - { - ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService(); - - // From https://docs.hcaptcha.com/#integration-testing-test-keys - string hCaptchaSecret = "0x0000000000000000000000000000000000000000"; - string hCaptchaToken = "10000000-aaaa-bbbb-cccc-000000000001"; - string hCaptchaSiteKey = "10000000-ffff-ffff-ffff-000000000001"; - HCaptchaResult? response = await captchaService.VerifyAsync(hCaptchaSecret, hCaptchaToken, hCaptchaSiteKey); - - Assert.NotNull(response); - Assert.True(response.Success); - } -} - -public class CaptchaServiceProvider -{ - public ServiceProvider ServiceProvider { get; } = CreateServiceProvider(); - public static ServiceProvider CreateServiceProvider() - { - IServiceCollection services = new ServiceCollection(); - - IConfigurationRoot configuration = new ConfigurationBuilder() - .SetBasePath(IntelliTect.Multitool.RepositoryPaths.GetDefaultRepoRoot()) - .AddJsonFile($"{nameof(EssentialCSharp)}.{nameof(Web)}/appsettings.json") - .Build(); - services.AddCaptchaService(configuration.GetSection(CaptchaOptions.CaptchaSender)); - // Add other necessary services here - - return services.BuildServiceProvider(); - } -} +namespace EssentialCSharp.Web.Extensions.Tests.Integration; + +[ClassDataSource(Shared = SharedType.PerClass)] +public class CaptchaTests(CaptchaServiceProvider serviceProvider) +{ + [Test] + [Timeout(30_000)] + public async Task CaptchaService_Verify_Success(CancellationToken cancellationToken) + { + ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService(); + + // From https://docs.hcaptcha.com/#integration-testing-test-keys + string hCaptchaSecret = "0x0000000000000000000000000000000000000000"; + string hCaptchaToken = "10000000-aaaa-bbbb-cccc-000000000001"; + string hCaptchaSiteKey = "10000000-ffff-ffff-ffff-000000000001"; + HCaptchaResult? response = await captchaService.VerifyAsync(hCaptchaSecret, hCaptchaToken, hCaptchaSiteKey); + + await Assert.That(response).IsNotNull(); + await Assert.That(response.Success).IsTrue(); + } +} + +public class CaptchaServiceProvider : IDisposable +{ + public ServiceProvider ServiceProvider { get; } = CreateServiceProvider(); + public static ServiceProvider CreateServiceProvider() + { + IServiceCollection services = new ServiceCollection(); + + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(IntelliTect.Multitool.RepositoryPaths.GetDefaultRepoRoot()) + .AddJsonFile($"{nameof(EssentialCSharp)}.{nameof(Web)}/appsettings.json") + .Build(); + services.AddCaptchaService(configuration.GetSection(CaptchaOptions.CaptchaSender)); + // Add other necessary services here + + return services.BuildServiceProvider(); + } + public void Dispose() + { + ServiceProvider.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs index 1944d309..6eda7819 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs @@ -4,104 +4,112 @@ namespace EssentialCSharp.Web.Tests; -public class ListingSourceCodeControllerTests +[ClassDataSource(Shared = SharedType.PerClass)] +public class ListingSourceCodeControllerTests(WebApplicationFactory factory) { - [Fact] + [Test] public async Task GetListing_WithValidChapterAndListing_Returns200WithContent() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/1"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + ListingSourceCodeResponse? result = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(result); - Assert.Equal(1, result.ChapterNumber); - Assert.Equal(1, result.ListingNumber); - Assert.NotEmpty(result.FileExtension); - Assert.NotEmpty(result.Content); + await Assert.That(result).IsNotNull(); + using (Assert.Multiple()) + { + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.ListingNumber).IsEqualTo(1); + await Assert.That(result.FileExtension).IsNotEmpty(); + await Assert.That(result.Content).IsNotEmpty(); + } } - [Fact] + [Test] public async Task GetListing_WithInvalidChapter_Returns404() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999/listing/1"); // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } - [Fact] + [Test] public async Task GetListing_WithInvalidListing_Returns404() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1/listing/999"); // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } - [Fact] + [Test] public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/1"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + List? results = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(results); - Assert.NotEmpty(results); - + await Assert.That(results).IsNotNull(); + await Assert.That(results).IsNotEmpty(); + // Verify all results are from chapter 1 - Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); - + foreach (var r in results) + { + using (Assert.Multiple()) + { + await Assert.That(r.ChapterNumber).IsEqualTo(1); + } + } + // Verify results are ordered by listing number - Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); - + await Assert.That(results).IsOrderedBy(r => r.ListingNumber); + // Verify each listing has required properties - Assert.All(results, r => + foreach (var r in results) { - Assert.NotEmpty(r.FileExtension); - Assert.NotEmpty(r.Content); - }); + using (Assert.Multiple()) + { + await Assert.That(r.FileExtension).IsNotEmpty(); + await Assert.That(r.Content).IsNotEmpty(); + } + } } - [Fact] + [Test] public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList() { // Arrange - using WebApplicationFactory factory = new(); HttpClient client = factory.CreateClient(); // Act using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/chapter/999"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + List? results = await response.Content.ReadFromJsonAsync>(); - Assert.NotNull(results); - Assert.Empty(results); + await Assert.That(results).IsNotNull(); + await Assert.That(results).IsEmpty(); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs index fbc1d595..fd781e03 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -9,7 +9,7 @@ namespace EssentialCSharp.Web.Tests; public class ListingSourceCodeServiceTests { - [Fact] + [Test] public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListing() { // Arrange @@ -19,14 +19,17 @@ public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListi ListingSourceCodeResponse? result = await service.GetListingAsync(1, 1); // Assert - Assert.NotNull(result); - Assert.Equal(1, result.ChapterNumber); - Assert.Equal(1, result.ListingNumber); - Assert.Equal("cs", result.FileExtension); - Assert.NotEmpty(result.Content); + await Assert.That(result).IsNotNull(); + using (Assert.Multiple()) + { + await Assert.That(result.ChapterNumber).IsEqualTo(1); + await Assert.That(result.ListingNumber).IsEqualTo(1); + await Assert.That(result.FileExtension).IsEqualTo("cs"); + await Assert.That(result.Content).IsNotEmpty(); + } } - [Fact] + [Test] public async Task GetListingAsync_WithInvalidChapter_ReturnsNull() { // Arrange @@ -36,10 +39,10 @@ public async Task GetListingAsync_WithInvalidChapter_ReturnsNull() ListingSourceCodeResponse? result = await service.GetListingAsync(999, 1); // Assert - Assert.Null(result); + await Assert.That(result).IsNull(); } - [Fact] + [Test] public async Task GetListingAsync_WithInvalidListing_ReturnsNull() { // Arrange @@ -49,10 +52,10 @@ public async Task GetListingAsync_WithInvalidListing_ReturnsNull() ListingSourceCodeResponse? result = await service.GetListingAsync(1, 999); // Assert - Assert.Null(result); + await Assert.That(result).IsNull(); } - [Fact] + [Test] public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtension() { // Arrange @@ -62,11 +65,11 @@ public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtens ListingSourceCodeResponse? result = await service.GetListingAsync(1, 2); // Assert - Assert.NotNull(result); - Assert.Equal("xml", result.FileExtension); + await Assert.That(result).IsNotNull(); + await Assert.That(result.FileExtension).IsEqualTo("xml"); } - [Fact] + [Test] public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings() { // Arrange @@ -76,16 +79,22 @@ public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings( IReadOnlyList results = await service.GetListingsByChapterAsync(1); // Assert - Assert.NotEmpty(results); - Assert.All(results, r => Assert.Equal(1, r.ChapterNumber)); - Assert.All(results, r => Assert.NotEmpty(r.Content)); - Assert.All(results, r => Assert.NotEmpty(r.FileExtension)); - + await Assert.That(results).IsNotEmpty(); + foreach (var r in results) + { + using (Assert.Multiple()) + { + await Assert.That(r.ChapterNumber).IsEqualTo(1); + await Assert.That(r.Content).IsNotEmpty(); + await Assert.That(r.FileExtension).IsNotEmpty(); + } + } + // Verify results are ordered - Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results); + await Assert.That(results).IsOrderedBy(r => r.ListingNumber); } - [Fact] + [Test] public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_ExcludesNonListingFiles() { // Arrange - Chapter 10 has Employee.cs which doesn't match the pattern @@ -95,17 +104,20 @@ public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_Exc IReadOnlyList results = await service.GetListingsByChapterAsync(10); // Assert - Assert.NotEmpty(results); - + await Assert.That(results).IsNotEmpty(); + // Ensure all results match the {CC}.{LL}.{ext} pattern - Assert.All(results, r => + foreach (var r in results) { - Assert.Equal(10, r.ChapterNumber); - Assert.InRange(r.ListingNumber, 1, 99); - }); + using (Assert.Multiple()) + { + await Assert.That(r.ChapterNumber).IsEqualTo(10); + await Assert.That(r.ListingNumber).IsBetween(1, 99); + } + } } - [Fact] + [Test] public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList() { // Arrange @@ -115,7 +127,7 @@ public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList( IReadOnlyList results = await service.GetListingsByChapterAsync(999); // Assert - Assert.Empty(results); + await Assert.That(results).IsEmpty(); } private static ListingSourceCodeService CreateService() @@ -144,4 +156,4 @@ private static DirectoryInfo GetTestDataPath() return testDataDirectory; } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs index 9280e3fd..ed5866fc 100644 --- a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs +++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs @@ -3,7 +3,8 @@ namespace EssentialCSharp.Web.Tests; -public class RouteConfigurationServiceTests : IClassFixture +[ClassDataSource(Shared = SharedType.PerClass)] +public class RouteConfigurationServiceTests { private readonly WebApplicationFactory _Factory; @@ -12,8 +13,8 @@ public RouteConfigurationServiceTests(WebApplicationFactory factory) _Factory = factory; } - [Fact] - public void GetStaticRoutes_ShouldReturnExpectedRoutes() + [Test] + public async Task GetStaticRoutes_ShouldReturnExpectedRoutes() { // Act var routes = _Factory.InServiceScope(serviceProvider => @@ -23,13 +24,13 @@ public void GetStaticRoutes_ShouldReturnExpectedRoutes() }); // Assert - Assert.NotEmpty(routes); + await Assert.That(routes).IsNotEmpty(); // 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); + await Assert.That(routes).Contains("home"); + await Assert.That(routes).Contains("about"); + await Assert.That(routes).Contains("guidelines"); + await Assert.That(routes).Contains("announcements"); + await Assert.That(routes).Contains("termsofservice"); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/SiteMappingTests.cs b/EssentialCSharp.Web.Tests/SiteMappingTests.cs index cf4c2c34..91b4e7d9 100644 --- a/EssentialCSharp.Web.Tests/SiteMappingTests.cs +++ b/EssentialCSharp.Web.Tests/SiteMappingTests.cs @@ -51,40 +51,40 @@ public static List GetSiteMap() ]; } - [Fact] - public void FindHelloWorldWithAnchorSlugReturnsCorrectSiteMap() + [Test] + public async Task FindHelloWorldWithAnchorSlugReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("hello-world#hello-world"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(HelloWorldSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(HelloWorldSiteMapping); } - [Fact] - public void FindCSyntaxFundamentalsWithSpacesReturnsCorrectSiteMap() + [Test] + public async Task FindCSyntaxFundamentalsWithSpacesReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("C# Syntax Fundamentals"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(CSyntaxFundamentalsSiteMapping); } - [Fact] - public void FindCSyntaxFundamentalsWithSpacesAndAnchorReturnsCorrectSiteMap() + [Test] + public async Task FindCSyntaxFundamentalsWithSpacesAndAnchorReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("C# Syntax Fundamentals#hello-world"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(CSyntaxFundamentalsSiteMapping); } - [Fact] - public void FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap() + [Test] + public async Task FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap() { SiteMapping? foundSiteMap = GetSiteMap().Find("c-syntax-fundamentals#hello-world"); - Assert.NotNull(foundSiteMap); - Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap); + await Assert.That(foundSiteMap).IsNotNull(); + await Assert.That(foundSiteMap).IsEquivalentTo(CSyntaxFundamentalsSiteMapping); } - [Fact] - public void FindPercentComplete_KeyIsNull_ReturnsNull() + [Test] + public async Task FindPercentComplete_KeyIsNull_ReturnsNull() { // Arrange @@ -92,29 +92,26 @@ public void FindPercentComplete_KeyIsNull_ReturnsNull() string? percent = GetSiteMap().FindPercentComplete(null!); // Assert - Assert.Null(percent); + await Assert.That(percent).IsNull(); } - [Theory] - [InlineData(" ")] - [InlineData("")] - public void FindPercentComplete_KeyIsWhiteSpace_ThrowsArgumentException(string? key) + [Test] + [Arguments(" ")] + [Arguments("")] + public async Task FindPercentComplete_KeyIsWhiteSpace_ThrowsArgumentException(string? key) { // Arrange // Act // Assert - Assert.Throws(() => - { - GetSiteMap().FindPercentComplete(key); - }); + await Assert.That(() => GetSiteMap().FindPercentComplete(key)).Throws(); } - [Theory] - [InlineData("hello-world", "50.00")] - [InlineData("c-syntax-fundamentals", "100.00")] - public void FindPercentComplete_ValidKey_Success(string? key, string result) + [Test] + [Arguments("hello-world", "50.00")] + [Arguments("c-syntax-fundamentals", "100.00")] + public async Task FindPercentComplete_ValidKey_Success(string? key, string result) { // Arrange @@ -122,11 +119,11 @@ public void FindPercentComplete_ValidKey_Success(string? key, string result) string? percent = GetSiteMap().FindPercentComplete(key); // Assert - Assert.Equal(result, percent); + await Assert.That(percent).IsEqualTo(result); } - [Fact] - public void FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() + [Test] + public async Task FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() { // Arrange IList siteMappings = new List(); @@ -135,11 +132,11 @@ public void FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() string? percent = siteMappings.FindPercentComplete("test"); // Assert - Assert.Equal("0.00", percent); + await Assert.That(percent).IsEqualTo("0.00"); } - [Fact] - public void FindPercentComplete_KeyNotFound_ReturnsZeroPercent() + [Test] + public async Task FindPercentComplete_KeyNotFound_ReturnsZeroPercent() { // Arrange @@ -147,6 +144,6 @@ public void FindPercentComplete_KeyNotFound_ReturnsZeroPercent() string? percent = GetSiteMap().FindPercentComplete("non-existent-key"); // Assert - Assert.Equal("0.00", percent); + await Assert.That(percent).IsEqualTo("0.00"); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index be8edc90..ba9e029a 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -1,13 +1,15 @@ +using System.IO; 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 +[NotInParallel("SitemapTests")] +[ClassDataSource(Shared = SharedType.PerClass)] +public class SitemapXmlHelpersTests { private readonly WebApplicationFactory _Factory; @@ -16,8 +18,8 @@ public SitemapXmlHelpersTests(WebApplicationFactory factory) _Factory = factory; } - [Fact] - public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() + [Test] + public async Task EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() { // Arrange var siteMappings = new List @@ -28,12 +30,11 @@ public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow() }; // Act & Assert - var exception = Record.Exception(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); - Assert.Null(exception); + await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)).ThrowsNothing(); } - [Fact] - public void EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsException() + [Test] + public async Task EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsException() { // Arrange - Two mappings for the same chapter/page both marked as canonical var siteMappings = new List @@ -43,15 +44,14 @@ public void EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsExc }; // Act & Assert - var exception = Assert.Throws(() => - SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); - - Assert.Contains("Chapter 1, Page 1", exception.Message); - Assert.Contains("more than one canonical link", exception.Message); + await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)) + .Throws() + .WithMessageContaining("Chapter 1, Page 1") + .And.HasMessageContaining("more than one canonical link"); } - [Fact] - public void EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException() + [Test] + public async Task EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException() { // Arrange - No mappings marked as canonical for this page var siteMappings = new List @@ -61,14 +61,13 @@ public void EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException() }; // Act & Assert - var exception = Assert.Throws(() => - SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)); - - Assert.Contains("Chapter 1, Page 1", exception.Message); + await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings)) + .Throws() + .WithMessageContaining("Chapter 1, Page 1"); } - [Fact] - public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes() + [Test] + public async Task GenerateSitemapXml_DoesNotIncludeIdentityRoutes() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -87,16 +86,16 @@ public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes() 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)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("Identity", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(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)); + await Assert.That(allUrls).Contains(url => url.Contains("/home", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).Contains(url => url.Contains("/about", StringComparison.OrdinalIgnoreCase)); } - [Fact] - public void GenerateSitemapXml_IncludesBaseUrl() + [Test] + public async Task GenerateSitemapXml_IncludesBaseUrl() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -112,16 +111,19 @@ public void GenerateSitemapXml_IncludesBaseUrl() baseUrl, out var nodes); - Assert.Contains(nodes, node => node.Url == baseUrl); + await Assert.That(nodes).Contains(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); + using (Assert.Multiple()) + { + await Assert.That(rootNode.Priority).IsEqualTo(1.0M); + await Assert.That(rootNode.ChangeFrequency).IsEqualTo(ChangeFrequency.Daily); + } } - [Fact] - public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() + [Test] + public async Task GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -145,13 +147,13 @@ public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml() 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")); + await Assert.That(allUrls).Contains(url => url.Contains("test-page-1", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("test-page-2", StringComparison.OrdinalIgnoreCase)); // Not marked for XML + await Assert.That(allUrls).Contains(url => url.Contains("test-page-3", StringComparison.OrdinalIgnoreCase)); } - [Fact] - public void GenerateSitemapXml_DoesNotIncludeIndexRoutes() + [Test] + public async Task GenerateSitemapXml_DoesNotIncludeIndexRoutes() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -170,11 +172,11 @@ public void GenerateSitemapXml_DoesNotIncludeIndexRoutes() 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)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase)); } - [Fact] - public void GenerateSitemapXml_DoesNotIncludeErrorRoutes() + [Test] + public async Task GenerateSitemapXml_DoesNotIncludeErrorRoutes() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -193,11 +195,11 @@ public void GenerateSitemapXml_DoesNotIncludeErrorRoutes() var allUrls = nodes.Select(n => n.Url).ToList(); // Should not include Error action routes - Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase)); + await Assert.That(allUrls).DoesNotContain(url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase)); } - [Fact] - public void GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping() + [Test] + public async Task GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping() { // Arrange var tempDir = new DirectoryInfo(Path.GetTempPath()); @@ -220,7 +222,7 @@ public void GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping() // Assert var siteMappingNode = nodes.First(node => node.Url.Contains("test-page-1")); - Assert.Equal(specificLastModified, siteMappingNode.LastModificationDate); + await Assert.That(siteMappingNode.LastModificationDate).IsEqualTo(specificLastModified); } private static SiteMapping CreateSiteMapping( @@ -246,4 +248,4 @@ private static SiteMapping CreateSiteMapping( lastModified: lastModified ); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/StringExtensionsTests.cs b/EssentialCSharp.Web.Tests/StringExtensionsTests.cs index 50116ab3..254db0d2 100644 --- a/EssentialCSharp.Web.Tests/StringExtensionsTests.cs +++ b/EssentialCSharp.Web.Tests/StringExtensionsTests.cs @@ -2,33 +2,33 @@ public class StringExtensionsTests { - [Theory] - [InlineData(" ExtraSpacing ", "extraspacing")] - [InlineData("Hello World", "hello-world")] - [InlineData("Coding the Publish–Subscribe Pattern with Multicast Delegates", "coding-the-publish-subscribe-pattern-with-multicast-delegates")] - [InlineData("C#", "c")] - [InlineData("C# Syntax Fundamentals", "c-syntax-fundamentals")] - [InlineData("C#_Syntax_Fundamentals", "c-syntax-fundamentals")] - [InlineData("C# Syntax_Fundamentals-for-me", "c-syntax-fundamentals-for-me")] - [InlineData("Bitwise Operators (<<, >>, |, &, ^, ~)", "bitwise-operators")] - [InlineData(".NET Standard", "net-standard")] - [InlineData("Working with System.Threading", "working-with-system-threading")] - public void SanitizeStringToOnlyHaveDashesAndLowerCase(string actual, string sanitized) + [Test] + [Arguments(" ExtraSpacing ", "extraspacing")] + [Arguments("Hello World", "hello-world")] + [Arguments("Coding the Publish–Subscribe Pattern with Multicast Delegates", "coding-the-publish-subscribe-pattern-with-multicast-delegates")] + [Arguments("C#", "c")] + [Arguments("C# Syntax Fundamentals", "c-syntax-fundamentals")] + [Arguments("C#_Syntax_Fundamentals", "c-syntax-fundamentals")] + [Arguments("C# Syntax_Fundamentals-for-me", "c-syntax-fundamentals-for-me")] + [Arguments("Bitwise Operators (<<, >>, |, &, ^, ~)", "bitwise-operators")] + [Arguments(".NET Standard", "net-standard")] + [Arguments("Working with System.Threading", "working-with-system-threading")] + public async Task SanitizeStringToOnlyHaveDashesAndLowerCase(string actual, string sanitized) { - Assert.Equal(sanitized, actual.Sanitize()); - Assert.Equal(sanitized, actual.Sanitize().Sanitize()); + await Assert.That(actual.Sanitize()).IsEqualTo(sanitized); + await Assert.That(actual.Sanitize().Sanitize()).IsEqualTo(sanitized); } - [Theory] - [InlineData("hello-world#hello-world", "hello-world")] - [InlineData("C#Syntax#hello-world", "csyntax")] - [InlineData("C#Syntax", "csyntax")] - [InlineData("cSyntax", "csyntax")] - [InlineData(".NET", "net")] - [InlineData("System.Threading", "system-threading")] - public void GetPotentialMatches(string actual, string match) + [Test] + [Arguments("hello-world#hello-world", "hello-world")] + [Arguments("C#Syntax#hello-world", "csyntax")] + [Arguments("C#Syntax", "csyntax")] + [Arguments("cSyntax", "csyntax")] + [Arguments(".NET", "net")] + [Arguments("System.Threading", "system-threading")] + public async Task GetPotentialMatches(string actual, string match) { var matches = actual.GetPotentialMatches().ToList(); - Assert.Contains(match, matches); + await Assert.That(matches).Contains(match); } -} +} \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/Usings.cs b/EssentialCSharp.Web.Tests/Usings.cs index 8c927eb7..e69de29b 100644 --- a/EssentialCSharp.Web.Tests/Usings.cs +++ b/EssentialCSharp.Web.Tests/Usings.cs @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 07e60731..70bc647a 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,6 +1,7 @@ using System.Data.Common; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; +using TUnit.Core.Interfaces; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; @@ -13,8 +14,17 @@ namespace EssentialCSharp.Web.Tests; -public sealed class WebApplicationFactory : WebApplicationFactory +public sealed class WebApplicationFactory : WebApplicationFactory, IAsyncInitializer { + public Task InitializeAsync() + { + // Force eager server initialization before tests run. + // This is thread-safe and prevents race conditions from parallel tests + // calling CreateClient() concurrently during lazy init. + _ = Server; + return Task.CompletedTask; + } + private static string SqlConnectionString => $"DataSource=file:{Guid.NewGuid()}?mode=memory&cache=shared"; private SqliteConnection? _Connection; diff --git a/global.json b/global.json index d50dabe4..7e5a4f3c 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "version": "10.0.103", "rollForward": "latestMinor" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file