diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs index 2c6f8576..9c33c488 100644 --- a/src/AppHost/AppHost.cs +++ b/src/AppHost/AppHost.cs @@ -31,4 +31,4 @@ // Exclude the compiler-generated Program class from coverage. [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "Aspire host bootstrap — not business logic")] -public partial class Program { } +internal partial class Program { } diff --git a/src/AppHost/Properties/AssemblyInfo.cs b/src/AppHost/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..0d2cad47 --- /dev/null +++ b/src/AppHost/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : AssemblyInfo.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : AppHost +//======================================================= + +[assembly: CLSCompliant(false)] diff --git a/src/Web/Components/Theme/ThemeProvider.razor.cs b/src/Web/Components/Theme/ThemeProvider.razor.cs index 483bd150..b232d23e 100644 --- a/src/Web/Components/Theme/ThemeProvider.razor.cs +++ b/src/Web/Components/Theme/ThemeProvider.razor.cs @@ -7,11 +7,14 @@ //Project Name : Web //======================================================= +using System.Diagnostics.CodeAnalysis; + using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; namespace MyBlog.Web.Components.Theme; +[SuppressMessage("Design", "CA1515:Consider making public types internal", Justification = "Razor component discovery and bUnit rendering require the component type to remain public.")] public partial class ThemeProvider : ComponentBase { [Inject] private IJSRuntime Js { get; set; } = default!; @@ -28,32 +31,43 @@ protected override async Task OnAfterRenderAsync(bool firstRender) return; } - CurrentColor = await GetStoredThemeValueAsync("themeManager.getColor", CurrentColor); - CurrentBrightness = await GetStoredThemeValueAsync("themeManager.getBrightness", CurrentBrightness); + var currentColor = await GetStoredThemeValueAsync("themeManager.getColor", CurrentColor).ConfigureAwait(false); + var currentBrightness = await GetStoredThemeValueAsync("themeManager.getBrightness", CurrentBrightness).ConfigureAwait(false); - await TryMarkInitializedAsync(); - await InvokeAsync(StateHasChanged); + await TryMarkInitializedAsync().ConfigureAwait(false); + await InvokeAsync(() => + { + CurrentColor = currentColor; + CurrentBrightness = currentBrightness; + StateHasChanged(); + }).ConfigureAwait(false); } public async Task SetColor(string color) { - CurrentColor = color; - StateHasChanged(); - await Js.InvokeVoidAsync("themeManager.setColor", color); + await Js.InvokeVoidAsync("themeManager.setColor", color).ConfigureAwait(false); + await InvokeAsync(() => + { + CurrentColor = color; + StateHasChanged(); + }).ConfigureAwait(false); } public async Task SetBrightness(string brightness) { - CurrentBrightness = brightness; - StateHasChanged(); - await Js.InvokeVoidAsync("themeManager.setBrightness", brightness); + await Js.InvokeVoidAsync("themeManager.setBrightness", brightness).ConfigureAwait(false); + await InvokeAsync(() => + { + CurrentBrightness = brightness; + StateHasChanged(); + }).ConfigureAwait(false); } private async Task GetStoredThemeValueAsync(string identifier, string fallback) { try { - return await Js.InvokeAsync(identifier); + return await Js.InvokeAsync(identifier).ConfigureAwait(false); } catch (JSException) { @@ -71,7 +85,7 @@ private async Task TryMarkInitializedAsync() { try { - await Js.InvokeVoidAsync("themeManager.markInitialized"); + await Js.InvokeVoidAsync("themeManager.markInitialized").ConfigureAwait(false); } catch (JSException) { diff --git a/src/Web/Data/MongoDbBlogPostRepository.cs b/src/Web/Data/MongoDbBlogPostRepository.cs index 0d183eb9..321bade4 100644 --- a/src/Web/Data/MongoDbBlogPostRepository.cs +++ b/src/Web/Data/MongoDbBlogPostRepository.cs @@ -14,52 +14,70 @@ internal sealed class MongoDbBlogPostRepository(IDbContextFactory { public async Task GetByIdAsync(ObjectId id, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - return await ctx.BlogPosts.AsNoTracking() + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + return await ctx.BlogPosts.AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id, ct).ConfigureAwait(false); + } } public async Task> GetAllAsync(CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - return await ctx.BlogPosts.AsNoTracking() + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + return await ctx.BlogPosts.AsNoTracking() .OrderByDescending(p => p.CreatedAt) .ToListAsync(ct).ConfigureAwait(false); + } } public async Task ExistsByCategoryAsync(ObjectId categoryId, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - return await ctx.BlogPosts.AsNoTracking() + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + return await ctx.BlogPosts.AsNoTracking() .AnyAsync(p => p.CategoryId == categoryId, ct).ConfigureAwait(false); + } } public async Task AddAsync(BlogPost post, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - await ctx.BlogPosts.AddAsync(post, ct).ConfigureAwait(false); - await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + await ctx.BlogPosts.AddAsync(post, ct).ConfigureAwait(false); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + } } public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var entry = ctx.Attach(post); - // Version was incremented by post.Update(); the original value in the DB is Version - 1. - // EF Core uses OriginalValue in the WHERE filter to detect concurrent modifications. - entry.Property(p => p.Version).OriginalValue = post.Version - 1; - entry.State = EntityState.Modified; - await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + var entry = ctx.Attach(post); + // Version was incremented by post.Update(); the original value in the DB is Version - 1. + // EF Core uses OriginalValue in the WHERE filter to detect concurrent modifications. + entry.Property(p => p.Version).OriginalValue = post.Version - 1; + entry.State = EntityState.Modified; + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + } } public async Task DeleteAsync(ObjectId id, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var post = await ctx.BlogPosts.FindAsync([id], ct).ConfigureAwait(false); - if (post is not null) + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) { - ctx.BlogPosts.Remove(post); - await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + var post = await ctx.BlogPosts.FindAsync([id], ct).ConfigureAwait(false); + if (post is not null) + { + ctx.BlogPosts.Remove(post); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + } } } } diff --git a/src/Web/Data/MongoDbCategoryRepository.cs b/src/Web/Data/MongoDbCategoryRepository.cs index 7b08f8b4..4ac5116d 100644 --- a/src/Web/Data/MongoDbCategoryRepository.cs +++ b/src/Web/Data/MongoDbCategoryRepository.cs @@ -14,57 +14,78 @@ internal sealed class MongoDbCategoryRepository(IDbContextFactory { public async Task GetByIdAsync(ObjectId id, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - return await ctx.Categories.AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == id, ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + return await ctx.Categories.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == id, ct).ConfigureAwait(false); + } } public async Task> GetAllAsync(CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - return await ctx.Categories.AsNoTracking() - .OrderBy(c => c.Name) - .ToListAsync(ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + return await ctx.Categories.AsNoTracking() + .OrderBy(c => c.Name) + .ToListAsync(ct).ConfigureAwait(false); + } } public async Task ExistsByNameAsync(string name, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var normalizedName = name.Trim(); - return await ctx.Categories.AsNoTracking() - .AnyAsync(c => c.Name == normalizedName, ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + var normalizedName = name.Trim(); + return await ctx.Categories.AsNoTracking() + .AnyAsync(c => c.Name == normalizedName, ct).ConfigureAwait(false); + } } public async Task ExistsByNameExcludingAsync(string name, ObjectId excludedId, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var normalizedName = name.Trim(); - return await ctx.Categories.AsNoTracking() - .AnyAsync(c => c.Name == normalizedName && c.Id != excludedId, ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + var normalizedName = name.Trim(); + return await ctx.Categories.AsNoTracking() + .AnyAsync(c => c.Name == normalizedName && c.Id != excludedId, ct).ConfigureAwait(false); + } } public async Task AddAsync(Category category, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - await ctx.Categories.AddAsync(category, ct).ConfigureAwait(false); - await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + await ctx.Categories.AddAsync(category, ct).ConfigureAwait(false); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + } } public async Task UpdateAsync(Category category, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - ctx.Categories.Update(category); - await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) + { + ctx.Categories.Update(category); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + } } public async Task DeleteAsync(ObjectId id, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var category = await ctx.Categories.FindAsync([id], ct).ConfigureAwait(false); - if (category is not null) + var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await using (ctx.ConfigureAwait(false)) { - ctx.Categories.Remove(category); - await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + var category = await ctx.Categories.FindAsync([id], ct).ConfigureAwait(false); + if (category is not null) + { + ctx.Categories.Remove(category); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); + } } } } diff --git a/src/Web/Infrastructure/FileStorage/LocalDiskFileStorage.cs b/src/Web/Infrastructure/FileStorage/LocalDiskFileStorage.cs index 6d68e58e..02f51797 100644 --- a/src/Web/Infrastructure/FileStorage/LocalDiskFileStorage.cs +++ b/src/Web/Infrastructure/FileStorage/LocalDiskFileStorage.cs @@ -58,10 +58,17 @@ public async Task AddFileAsync(FileData file) var uniqueName = $"{Guid.NewGuid()}{extension}"; var filePath = Path.Combine(uploadsPath, uniqueName); - await using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write); - await file.Content.CopyToAsync(fs).ConfigureAwait(false); + var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write); + try + { + await file.Content.CopyToAsync(fs).ConfigureAwait(false); - LogFileSaved(_logger, uniqueName); - return uniqueName; + LogFileSaved(_logger, uniqueName); + return uniqueName; + } + finally + { + await fs.DisposeAsync().ConfigureAwait(false); + } } } diff --git a/src/Web/Security/RoleClaimsHelper.cs b/src/Web/Security/RoleClaimsHelper.cs index 22241270..3ed703b7 100644 --- a/src/Web/Security/RoleClaimsHelper.cs +++ b/src/Web/Security/RoleClaimsHelper.cs @@ -73,7 +73,7 @@ public static IReadOnlyList ExpandRoleValues(string? claimValue) var trimmed = claimValue.Trim(); - if (trimmed.StartsWith("[", StringComparison.Ordinal)) + if (trimmed.StartsWith('[')) { try { diff --git a/tests/AppHost.Tests/EnvVarTests.cs b/tests/AppHost.Tests/EnvVarTests.cs index a201fa7f..c7ff29d1 100644 --- a/tests/AppHost.Tests/EnvVarTests.cs +++ b/tests/AppHost.Tests/EnvVarTests.cs @@ -11,6 +11,8 @@ using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; + namespace AppHost.Tests; /// @@ -35,13 +37,16 @@ public async Task WebResourceHasMongoDbConnectionString() .Single(static r => r.Name == "web"); // Act -#pragma warning disable CS0618 // Type or member is obsolete - var envVars = await webResource.GetEnvironmentVariableValuesAsync( - DistributedApplicationOperation.Publish); -#pragma warning restore CS0618 // Type or member is obsolete + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish); + var resolvedConfig = await ExecutionConfigurationBuilder + .Create(webResource) + .WithEnvironmentVariablesConfig() + .BuildAsync(executionContext, NullLogger.Instance, TestContext.Current.CancellationToken); // Assert - envVars.Should().ContainKey("ConnectionStrings__myblog"); + resolvedConfig.EnvironmentVariables + .Select(kvp => kvp.Key) + .Should().Contain("ConnectionStrings__myblog"); } [Fact] @@ -61,12 +66,15 @@ public async Task WebResourceHasRedisConnectionString() .Single(static r => r.Name == "web"); // Act -#pragma warning disable CS0618 // Type or member is obsolete - var envVars = await webResource.GetEnvironmentVariableValuesAsync( - DistributedApplicationOperation.Publish); -#pragma warning restore CS0618 // Type or member is obsolete + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish); + var resolvedConfig = await ExecutionConfigurationBuilder + .Create(webResource) + .WithEnvironmentVariablesConfig() + .BuildAsync(executionContext, NullLogger.Instance, TestContext.Current.CancellationToken); // Assert - envVars.Should().ContainKey("ConnectionStrings__redis"); + resolvedConfig.EnvironmentVariables + .Select(kvp => kvp.Key) + .Should().Contain("ConnectionStrings__redis"); } } diff --git a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs index e9c4b74e..87987572 100644 --- a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs @@ -41,7 +41,7 @@ public sealed class MongoSeedDataIntegrationTests(ClearCommandAppFixture fixture public async Task SeedMyBlogData_Inserts_Expected_Documents_Into_BlogPosts_Collection() { // Arrange — drop and recreate an empty blogposts collection - var client = new MongoClient(fixture.MongoConnectionString); + using var client = new MongoClient(fixture.MongoConnectionString); var db = client.GetDatabase("myblog"); await db.DropCollectionAsync("blogposts", TestContext.Current.CancellationToken); await db.CreateCollectionAsync("blogposts", cancellationToken: TestContext.Current.CancellationToken); @@ -76,7 +76,7 @@ public async Task SeedMyBlogData_Inserts_Expected_Documents_Into_BlogPosts_Colle public async Task SeedMyBlogData_Concurrent_Invocations_Allow_Only_One_Run() { // Arrange - var client = new MongoClient(fixture.MongoConnectionString); + using var client = new MongoClient(fixture.MongoConnectionString); var db = client.GetDatabase("myblog"); await db.DropCollectionAsync("blogposts", TestContext.Current.CancellationToken); await db.CreateCollectionAsync("blogposts", cancellationToken: TestContext.Current.CancellationToken); @@ -129,7 +129,7 @@ public async Task SeedMyBlogData_Concurrent_Invocations_Allow_Only_One_Run() public async Task SeedMyBlogData_Empty_Database_Results_In_BlogPosts_After_Seed() { // Arrange — drop the entire database so no collection exists - var client = new MongoClient(fixture.MongoConnectionString); + using var client = new MongoClient(fixture.MongoConnectionString); await client.DropDatabaseAsync("myblog", TestContext.Current.CancellationToken); var annotation = GetAnnotation(); diff --git a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs index 49920101..483a93a5 100644 --- a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs @@ -92,21 +92,21 @@ public async Task ShowMyBlogStats_Concurrent_Invocations_Allow_Only_One_Run() // entirely synchronously (fast local MongoDB) and release the semaphore before the // second call even starts, causing both to succeed (flake). var ct = TestContext.Current.CancellationToken; - using var startGate = new SemaphoreSlim(0, 2); + var startGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var firstTask = Task.Run(async () => { - await startGate.WaitAsync(ct); + await startGate.Task.WaitAsync(ct); return await annotation.ExecuteCommand(MakeContext()); }, ct); var secondTask = Task.Run(async () => { - await startGate.WaitAsync(ct); + await startGate.Task.WaitAsync(ct); return await annotation.ExecuteCommand(MakeContext()); }, ct); - startGate.Release(2); // open the gate — both workers race for _dbMutex + startGate.TrySetResult(true); // open the gate — both workers race for _dbMutex var results = await Task.WhenAll(firstTask, secondTask); // Assert @@ -125,7 +125,7 @@ public async Task ShowMyBlogStats_Concurrent_Invocations_Allow_Only_One_Run() private async Task PrepareAsync(int blogPostCount = 0) { - var client = new MongoClient(fixture.MongoConnectionString); + using var client = new MongoClient(fixture.MongoConnectionString); await client.DropDatabaseAsync("myblog", TestContext.Current.CancellationToken); if (blogPostCount > 0) { diff --git a/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs b/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs index c119c202..828efcb7 100644 --- a/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs @@ -21,14 +21,16 @@ public sealed class LayoutThemeToggleTests : BasePlaywrightTests { public LayoutThemeToggleTests(AspireManager aspireManager) : base(aspireManager) { } - [Theory(Skip = "The reload/bootstrap persistence path still flakes in AppHost because a seeded localStorage reload can race the post-hydration readiness marker; click-through runtime coverage lives in ThemeToggleInteractionTests.")] + [Theory] [InlineData("light", false)] [InlineData("dark", true)] public async Task ThemeToggle_ClickingSwitchesBrightnessAndHtmlDarkClass(string initialBrightness, bool initialHasDarkClass) { - // Arrange / Act / Assert + // Arrange await InteractWithPageAsync("web", async page => { + var runtimeDiagnostics = ThemeToggleTestRuntime.BrowserRuntimeDiagnostics.Attach(page); + // Arrange — align system preference with the requested starting brightness. await page.EmulateMediaAsync(new() { @@ -45,36 +47,28 @@ await page.EvaluateAsync( await page.ReloadAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - await page.WaitForTimeoutAsync(5000); var toggleButton = page.Locator("button[aria-label*=\"Toggle dark mode\"]").First; await toggleButton.WaitForAsync(); - var initialDeadline = DateTime.UtcNow.AddSeconds(10); - var startingDarkClass = false; - string? startingStoredBrightness = null; - string? initialLabel = null; - - while (DateTime.UtcNow < initialDeadline) + var initialState = await ThemeToggleTestRuntime.WaitForThemeStateAsync( + page, + toggleButton, + initialBrightness, + initialHasDarkClass); + var startingSignals = initialState.Signals; + if (!initialState.MatchedExpectedState && !initialState.SawTrustworthyInteractiveState) { - startingDarkClass = await page.EvaluateAsync("() => document.documentElement.classList.contains('dark')"); - startingStoredBrightness = await page.EvaluateAsync("() => localStorage.getItem('theme-mode')"); - initialLabel = await toggleButton.GetAttributeAsync("aria-label"); - - if (startingDarkClass == initialHasDarkClass - && startingStoredBrightness == initialBrightness - && (initialLabel?.Contains($"currently {initialBrightness}", StringComparison.Ordinal) ?? false)) - { - break; - } - - await Task.Delay(250); + var assetDiagnostics = await ThemeToggleTestRuntime.ReadAssetFetchDiagnosticsAsync(page); + Assert.Skip($"AppHost Testing never reached a trustworthy interactive theme state for the reload/bootstrap flow seeded with '{initialBrightness}'. Observed after reload while waiting for the seeded state: {ThemeToggleTestRuntime.DescribeSignals(startingSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); } - // Assert — precondition matches the stored theme mode. - startingDarkClass.Should().Be(initialHasDarkClass); - startingStoredBrightness.Should().Be(initialBrightness); - initialLabel.Should().Contain($"currently {initialBrightness}", + // Assert + startingSignals.HasDarkClass.Should().Be(initialHasDarkClass, + because: "the seeded reload/bootstrap path should restore the requested html dark class before interaction"); + startingSignals.StoredBrightness.Should().Be(initialBrightness, + because: "the seeded reload/bootstrap path should restore the requested brightness in theme-mode before interaction"); + startingSignals.AriaLabel.Should().Contain($"currently {initialBrightness}", because: "the live toggle should reflect the stored brightness before interaction"); // Act — click the toggle and wait for the live page state to flip. @@ -82,33 +76,27 @@ await page.EvaluateAsync( var expectedBrightness = initialBrightness == "light" ? "dark" : "light"; var expectedDarkClass = expectedBrightness == "dark"; - - var endingDeadline = DateTime.UtcNow.AddSeconds(10); - var endingDarkClass = startingDarkClass; - var endingStoredBrightness = startingStoredBrightness; - var ariaLabel = initialLabel; - - while (DateTime.UtcNow < endingDeadline) + var toggledState = await ThemeToggleTestRuntime.WaitForThemeStateAsync( + page, + toggleButton, + expectedBrightness, + expectedDarkClass); + + var endingSignals = toggledState.Signals; + if (!toggledState.MatchedExpectedState + && !toggledState.SawTrustworthyInteractiveState + && !initialState.SawTrustworthyInteractiveState) { - endingDarkClass = await page.EvaluateAsync("() => document.documentElement.classList.contains('dark')"); - endingStoredBrightness = await page.EvaluateAsync("() => localStorage.getItem('theme-mode')"); - ariaLabel = await toggleButton.GetAttributeAsync("aria-label"); - - if (endingDarkClass == expectedDarkClass - && endingStoredBrightness == expectedBrightness - && (ariaLabel?.Contains($"currently {expectedBrightness}", StringComparison.Ordinal) ?? false)) - { - break; - } - - await Task.Delay(250); + var assetDiagnostics = await ThemeToggleTestRuntime.ReadAssetFetchDiagnosticsAsync(page); + Assert.Skip($"AppHost Testing lost a trustworthy interactive theme state after clicking the reload/bootstrap toggle seeded with '{initialBrightness}'. Observed after clicking: {ThemeToggleTestRuntime.DescribeSignals(endingSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); } - endingDarkClass.Should().Be(expectedDarkClass, + // Assert + endingSignals.HasDarkClass.Should().Be(expectedDarkClass, because: "clicking the theme toggle should update the html dark class"); - endingStoredBrightness.Should().Be(expectedBrightness, + endingSignals.StoredBrightness.Should().Be(expectedBrightness, because: "clicking the theme toggle should persist the new brightness in theme-mode"); - ariaLabel.Should().Contain($"currently {expectedBrightness}", + endingSignals.AriaLabel.Should().Contain($"currently {expectedBrightness}", because: "the live toggle label should reflect the updated brightness state"); }); } diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs index 81538f05..a6528e2a 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs @@ -27,7 +27,7 @@ public async Task ThemeToggle_DarkMode_PersistsAfterNavigatingToBlogPosts() // Arrange await InteractWithPageAsync("web", async page => { - var runtimeDiagnostics = BrowserRuntimeDiagnostics.Attach(page); + var runtimeDiagnostics = ThemeToggleTestRuntime.BrowserRuntimeDiagnostics.Attach(page); await page.EmulateMediaAsync(new() { @@ -41,25 +41,32 @@ await page.EmulateMediaAsync(new() var toggleButton = page.Locator("button[aria-label*=\"Toggle dark mode\"]").First; await toggleButton.WaitForAsync(); - var becameInteractive = await WaitForThemeReadyAsync(page, toggleButton); + var becameInteractive = await ThemeToggleTestRuntime.WaitForThemeReadyAsync(page, toggleButton); if (!becameInteractive) { - var blockedSignals = await ReadThemeSignalsAsync(page, toggleButton); - var assetDiagnostics = await ReadAssetFetchDiagnosticsAsync(page); - Assert.Skip($"AppHost Testing never reached a trustworthy interactive theme state for the /blog persistence flow. Observed on the home page before toggling: {DescribeSignals(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); + var blockedSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); + var assetDiagnostics = await ThemeToggleTestRuntime.ReadAssetFetchDiagnosticsAsync(page); + Assert.Skip($"AppHost Testing never reached a trustworthy interactive theme state for the /blog persistence flow. Observed on the home page before toggling: {ThemeToggleTestRuntime.DescribeSignals(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); } await toggleButton.ClickAsync(); - var toggledToDark = await WaitForThemeStateAsync(page, toggleButton, expectedBrightness: "dark", expectedDarkClass: true); - if (!toggledToDark) + var toggledState = await ThemeToggleTestRuntime.WaitForThemeStateAsync(page, toggleButton, expectedBrightness: "dark", expectedDarkClass: true); + var themeSignalsBeforeNavigation = toggledState.Signals; + if (!toggledState.MatchedExpectedState + && !toggledState.SawTrustworthyInteractiveState + && !becameInteractive) { - var blockedSignals = await ReadThemeSignalsAsync(page, toggleButton); - var assetDiagnostics = await ReadAssetFetchDiagnosticsAsync(page); - Assert.Skip($"AppHost Testing never applied the light→dark toggle deterministically, so the /blog persistence scenario cannot be trusted. Observed after clicking the home-page toggle: {DescribeSignals(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); + var assetDiagnostics = await ThemeToggleTestRuntime.ReadAssetFetchDiagnosticsAsync(page); + Assert.Skip($"AppHost Testing never applied the light→dark toggle deterministically because the page never reached a trustworthy interactive state after the click. Observed after clicking the home-page toggle: {ThemeToggleTestRuntime.DescribeSignals(themeSignalsBeforeNavigation)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); } - var themeSignalsBeforeNavigation = await ReadThemeSignalsAsync(page, toggleButton); + themeSignalsBeforeNavigation.HasDarkClass.Should().BeTrue( + because: "the home-page toggle should apply the html dark class before navigating to Blog Posts"); + themeSignalsBeforeNavigation.StoredBrightness.Should().Be("dark", + because: "the home-page toggle should persist dark mode before navigating to Blog Posts"); + themeSignalsBeforeNavigation.AriaLabel.Should().Contain("currently dark", + because: "the home-page toggle label should describe the updated dark-mode state before navigation"); var blogPostsLink = page.Locator("nav[aria-label=\"Main navigation\"] a[href=\"blog\"]").First; await blogPostsLink.ClickAsync(); @@ -72,15 +79,16 @@ await page.EmulateMediaAsync(new() var blogToggleButton = page.Locator("button[aria-label*=\"Toggle dark mode\"]").First; await blogToggleButton.WaitForAsync(); - var persistedOnBlogPage = await WaitForThemeStateAsync(page, blogToggleButton, expectedBrightness: "dark", expectedDarkClass: true); - if (!persistedOnBlogPage) + var persistedOnBlogPage = await ThemeToggleTestRuntime.WaitForThemeStateAsync(page, blogToggleButton, expectedBrightness: "dark", expectedDarkClass: true); + var themeSignalsAfterNavigation = persistedOnBlogPage.Signals; + if (!persistedOnBlogPage.MatchedExpectedState + && !persistedOnBlogPage.SawTrustworthyInteractiveState + && !becameInteractive) { - var blockedSignals = await ReadThemeSignalsAsync(page, blogToggleButton); - var assetDiagnostics = await ReadAssetFetchDiagnosticsAsync(page); - Assert.Skip($"AppHost Testing reached /blog but the persisted dark-mode signals were not trustworthy after navigation. Expected the chosen theme to hold on the Blog Posts page, but observed: {DescribeSignals(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); + var assetDiagnostics = await ThemeToggleTestRuntime.ReadAssetFetchDiagnosticsAsync(page); + Assert.Skip($"AppHost Testing reached /blog but the persisted dark-mode signals were not trustworthy after navigation. Expected the chosen theme to hold on the Blog Posts page, but observed: {ThemeToggleTestRuntime.DescribeSignals(themeSignalsAfterNavigation)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); } - var themeSignalsAfterNavigation = await ReadThemeSignalsAsync(page, blogToggleButton); var headingText = await blogHeading.TextContentAsync(); // Assert @@ -97,188 +105,4 @@ await page.EmulateMediaAsync(new() }); } - private static async Task WaitForThemeReadyAsync(IPage page, ILocator toggleButton, TimeSpan? timeout = null) - { - var deadline = DateTime.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(10)); - - while (DateTime.UtcNow < deadline) - { - var signals = await ReadThemeSignalsAsync(page, toggleButton); - if (signals.IsTrustworthyInteractiveState()) - { - return true; - } - - await Task.Delay(250); - } - - return false; - } - - private static async Task WaitForThemeStateAsync( - IPage page, - ILocator toggleButton, - string expectedBrightness, - bool expectedDarkClass, - TimeSpan? timeout = null) - { - var deadline = DateTime.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(10)); - - while (DateTime.UtcNow < deadline) - { - var signals = await ReadThemeSignalsAsync(page, toggleButton); - if (signals.IsTrustworthyInteractiveState() - && signals.HasDarkClass == expectedDarkClass - && string.Equals(signals.StoredBrightness, expectedBrightness, StringComparison.Ordinal) - && (signals.AriaLabel?.Contains($"currently {expectedBrightness}", StringComparison.Ordinal) ?? false)) - { - return true; - } - - await Task.Delay(250); - } - - return false; - } - - private static async Task ReadThemeSignalsAsync(IPage page, ILocator toggleButton) - { - var hasDarkClass = await page.EvaluateAsync("() => document.documentElement.classList.contains('dark')"); - var storedBrightness = await page.EvaluateAsync("() => localStorage.getItem('theme-mode')"); - var storedColor = await page.EvaluateAsync("() => localStorage.getItem('theme-color')"); - var ariaLabel = await toggleButton.GetAttributeAsync("aria-label"); - var readinessMarker = await page.EvaluateAsync("() => document.documentElement.getAttribute('data-theme-ready')"); - var hasThemeManager = await page.EvaluateAsync("() => !!window.themeManager"); - var hasBlazor = await page.EvaluateAsync("() => !!window.Blazor"); - var sawThemeScriptResource = await page.EvaluateAsync("() => performance.getEntriesByType('resource').some(entry => entry.name.includes('/js/theme.js'))"); - var sawBlazorScriptResource = await page.EvaluateAsync("() => performance.getEntriesByType('resource').some(entry => entry.name.includes('blazor.web.js'))"); - - return new ThemeSignals( - hasDarkClass, - storedBrightness, - storedColor, - ariaLabel, - readinessMarker, - hasThemeManager, - hasBlazor, - sawThemeScriptResource, - sawBlazorScriptResource); - } - - private static async Task ReadAssetFetchDiagnosticsAsync(IPage page) - { - var diagnostics = await page.EvaluateAsync( - """ - async () => { - const interestingPaths = new Set([ - '/_framework/blazor.web.js', - '/Components/Layout/ReconnectModal.razor.js', - '/Web.styles.css', - '/MyBlog.Web.styles.css' - ]); - - for (const element of Array.from(document.querySelectorAll('script[src], link[href]'))) { - const raw = element.getAttribute('src') ?? element.getAttribute('href'); - if (!raw) { - continue; - } - - try { - const path = new URL(raw, window.location.href).pathname; - if (/blazor\.web|ReconnectModal|styles\.css/i.test(path)) { - interestingPaths.add(path); - } - } catch { - // Ignore malformed URLs in diagnostics. - } - } - - const results = []; - for (const path of interestingPaths) { - try { - const response = await fetch(path, { cache: 'no-store' }); - const body = await response.text(); - results.push({ - path, - status: response.status, - ok: response.ok, - body: body.replace(/\s+/g, ' ').slice(0, 220) - }); - } catch (error) { - results.push({ - path, - error: String(error) - }); - } - } - - return JSON.stringify(results); - } - """); - - return string.IsNullOrWhiteSpace(diagnostics) ? "[]" : diagnostics; - } - - private static string DescribeSignals(ThemeSignals signals) - { - var darkClass = signals.HasDarkClass ? "true" : "false"; - var themeManager = signals.HasThemeManager ? "present" : "missing"; - var blazor = signals.HasBlazor ? "present" : "missing"; - var themeScript = signals.SawThemeScriptResource ? "requested" : "not-requested"; - var blazorScript = signals.SawBlazorScriptResource ? "requested" : "not-requested"; - - return $"data-theme-ready='{signals.ReadinessMarker ?? ""}', aria-label='{signals.AriaLabel ?? ""}', html.dark={darkClass}, localStorage['theme-mode']='{signals.StoredBrightness ?? ""}', localStorage['theme-color']='{signals.StoredColor ?? ""}', window.themeManager={themeManager}, window.Blazor={blazor}, theme.js={themeScript}, blazor.web.js={blazorScript}"; - } - - private sealed record ThemeSignals( - bool HasDarkClass, - string? StoredBrightness, - string? StoredColor, - string? AriaLabel, - string? ReadinessMarker, - bool HasThemeManager, - bool HasBlazor, - bool SawThemeScriptResource, - bool SawBlazorScriptResource) - { - public bool IsTrustworthyInteractiveState() => - (string.Equals(ReadinessMarker, "true", StringComparison.Ordinal) - || (HasThemeManager && HasBlazor)) - && !string.IsNullOrWhiteSpace(AriaLabel); - } - - private sealed class BrowserRuntimeDiagnostics - { - private readonly List _events = []; - private readonly object _gate = new(); - - public static BrowserRuntimeDiagnostics Attach(IPage page) - { - var diagnostics = new BrowserRuntimeDiagnostics(); - - page.Console += (_, message) => diagnostics.Add($"console.{message.Type}: {message.Text}"); - page.PageError += (_, message) => diagnostics.Add($"pageerror: {message}"); - page.RequestFailed += (_, request) => diagnostics.Add($"requestfailed: {request.Method} {request.Url} :: {request.Failure}"); - - return diagnostics; - } - - public string Describe() - { - lock (_gate) - { - return _events.Count == 0 - ? "no console, pageerror, or requestfailed events captured" - : string.Join(" | ", _events.TakeLast(6)); - } - } - - private void Add(string message) - { - lock (_gate) - { - _events.Add(message); - } - } - } } diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs new file mode 100644 index 00000000..d9829550 --- /dev/null +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs @@ -0,0 +1,209 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : ThemeToggleTestRuntime.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : AppHost.Tests +//======================================================= + +namespace AppHost.Tests.Tests.Layout; + +internal static class ThemeToggleTestRuntime +{ + internal static async Task WaitForThemeReadyAsync(IPage page, ILocator toggleButton, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(10)); + + while (DateTime.UtcNow < deadline) + { + var signals = await ReadThemeSignalsAsync(page, toggleButton); + if (signals.IsTrustworthyInteractiveState()) + { + return true; + } + + await Task.Delay(250); + } + + return false; + } + + internal static async Task WaitForThemeStateAsync( + IPage page, + ILocator toggleButton, + string expectedBrightness, + bool expectedDarkClass, + TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(10)); + ThemeSignals? lastSignals = null; + var sawTrustworthyInteractiveState = false; + + while (DateTime.UtcNow < deadline) + { + lastSignals = await ReadThemeSignalsAsync(page, toggleButton); + if (lastSignals.IsTrustworthyInteractiveState()) + { + sawTrustworthyInteractiveState = true; + + if (lastSignals.HasDarkClass == expectedDarkClass + && string.Equals(lastSignals.StoredBrightness, expectedBrightness, StringComparison.Ordinal) + && (lastSignals.AriaLabel?.Contains($"currently {expectedBrightness}", StringComparison.Ordinal) ?? false)) + { + return new(lastSignals, true, sawTrustworthyInteractiveState); + } + } + + await Task.Delay(250); + } + + lastSignals ??= await ReadThemeSignalsAsync(page, toggleButton); + sawTrustworthyInteractiveState |= lastSignals.IsTrustworthyInteractiveState(); + + return new(lastSignals, false, sawTrustworthyInteractiveState); + } + + internal static async Task ReadThemeSignalsAsync(IPage page, ILocator toggleButton) + { + var hasDarkClass = await page.EvaluateAsync("() => document.documentElement.classList.contains('dark')"); + var storedBrightness = await page.EvaluateAsync("() => localStorage.getItem('theme-mode')"); + var storedColor = await page.EvaluateAsync("() => localStorage.getItem('theme-color')"); + var ariaLabel = await toggleButton.GetAttributeAsync("aria-label"); + var readinessMarker = await page.EvaluateAsync("() => document.documentElement.getAttribute('data-theme-ready')"); + var hasThemeManager = await page.EvaluateAsync("() => !!window.themeManager"); + var hasBlazor = await page.EvaluateAsync("() => !!window.Blazor"); + var sawThemeScriptResource = await page.EvaluateAsync("() => performance.getEntriesByType('resource').some(entry => entry.name.includes('/js/theme.js'))"); + var sawBlazorScriptResource = await page.EvaluateAsync("() => performance.getEntriesByType('resource').some(entry => entry.name.includes('blazor.web.js'))"); + + return new ThemeSignals( + hasDarkClass, + storedBrightness, + storedColor, + ariaLabel, + readinessMarker, + hasThemeManager, + hasBlazor, + sawThemeScriptResource, + sawBlazorScriptResource); + } + + internal static async Task ReadAssetFetchDiagnosticsAsync(IPage page) + { + var diagnostics = await page.EvaluateAsync( + """ + async () => { + const interestingPaths = new Set([ + '/_framework/blazor.web.js', + '/Components/Layout/ReconnectModal.razor.js', + '/Web.styles.css', + '/MyBlog.Web.styles.css' + ]); + + for (const element of Array.from(document.querySelectorAll('script[src], link[href]'))) { + const raw = element.getAttribute('src') ?? element.getAttribute('href'); + if (!raw) { + continue; + } + + try { + const path = new URL(raw, window.location.href).pathname; + if (/blazor\.web|ReconnectModal|styles\.css/i.test(path)) { + interestingPaths.add(path); + } + } catch { + // Ignore malformed URLs in diagnostics. + } + } + + const results = []; + for (const path of interestingPaths) { + try { + const response = await fetch(path, { cache: 'no-store' }); + const body = await response.text(); + results.push({ + path, + status: response.status, + ok: response.ok, + body: body.replace(/\s+/g, ' ').slice(0, 220) + }); + } catch (error) { + results.push({ + path, + error: String(error) + }); + } + } + + return JSON.stringify(results); + } + """); + + return string.IsNullOrWhiteSpace(diagnostics) ? "[]" : diagnostics; + } + + internal static string DescribeSignals(ThemeSignals signals) + { + var darkClass = signals.HasDarkClass ? "true" : "false"; + var themeManager = signals.HasThemeManager ? "present" : "missing"; + var blazor = signals.HasBlazor ? "present" : "missing"; + var themeScript = signals.SawThemeScriptResource ? "requested" : "not-requested"; + var blazorScript = signals.SawBlazorScriptResource ? "requested" : "not-requested"; + + return $"data-theme-ready='{signals.ReadinessMarker ?? ""}', aria-label='{signals.AriaLabel ?? ""}', html.dark={darkClass}, localStorage['theme-mode']='{signals.StoredBrightness ?? ""}', localStorage['theme-color']='{signals.StoredColor ?? ""}', window.themeManager={themeManager}, window.Blazor={blazor}, theme.js={themeScript}, blazor.web.js={blazorScript}"; + } + + internal sealed record ThemeSignals( + bool HasDarkClass, + string? StoredBrightness, + string? StoredColor, + string? AriaLabel, + string? ReadinessMarker, + bool HasThemeManager, + bool HasBlazor, + bool SawThemeScriptResource, + bool SawBlazorScriptResource) + { + internal bool IsTrustworthyInteractiveState() => + (string.Equals(ReadinessMarker, "true", StringComparison.Ordinal) + || (HasThemeManager && HasBlazor)) + && !string.IsNullOrWhiteSpace(AriaLabel); + } + + internal sealed record ThemeStateWaitResult(ThemeSignals Signals, bool MatchedExpectedState, bool SawTrustworthyInteractiveState); + + internal sealed class BrowserRuntimeDiagnostics + { + private readonly List _events = []; + private readonly object _gate = new(); + + internal static BrowserRuntimeDiagnostics Attach(IPage page) + { + var diagnostics = new BrowserRuntimeDiagnostics(); + + page.Console += (_, message) => diagnostics.Add($"console.{message.Type}: {message.Text}"); + page.PageError += (_, message) => diagnostics.Add($"pageerror: {message}"); + page.RequestFailed += (_, request) => diagnostics.Add($"requestfailed: {request.Method} {request.Url} :: {request.Failure}"); + + return diagnostics; + } + + internal string Describe() + { + lock (_gate) + { + return _events.Count == 0 + ? "no console, pageerror, or requestfailed events captured" + : string.Join(" | ", _events.TakeLast(6)); + } + } + + private void Add(string message) + { + lock (_gate) + { + _events.Add(message); + } + } + } +} diff --git a/tests/Architecture.Tests/Architecture.Tests.csproj b/tests/Architecture.Tests/Architecture.Tests.csproj index dfdd0e2e..5fcff417 100644 --- a/tests/Architecture.Tests/Architecture.Tests.csproj +++ b/tests/Architecture.Tests/Architecture.Tests.csproj @@ -7,7 +7,7 @@ false true MyBlog.Architecture.Tests - $(NoWarn);CA2007 + $(NoWarn);CA2007;CA1014 diff --git a/tests/Architecture.Tests/DomainLayerTests.cs b/tests/Architecture.Tests/DomainLayerTests.cs index 59afd179..80bbdbda 100644 --- a/tests/Architecture.Tests/DomainLayerTests.cs +++ b/tests/Architecture.Tests/DomainLayerTests.cs @@ -54,7 +54,7 @@ public void Domain_Should_Not_Have_InMemoryRepository() // Act var types = Types.InAssembly(assembly) .That() - .HaveNameEndingWith("InMemory") + .HaveNameEndingWith("InMemory", StringComparison.Ordinal) .GetTypes(); // Assert diff --git a/tests/Architecture.Tests/VsaLayerTests.cs b/tests/Architecture.Tests/VsaLayerTests.cs index 0f092bf4..b3ff5298 100644 --- a/tests/Architecture.Tests/VsaLayerTests.cs +++ b/tests/Architecture.Tests/VsaLayerTests.cs @@ -36,7 +36,7 @@ public void Handlers_Should_HaveNameEndingWithHandler_And_BeSealed() // Arrange / Act — All types named *Handler in Web assembly should be sealed classes var result = Types.InAssembly(WebAssembly) .That() - .HaveNameEndingWith("Handler") + .HaveNameEndingWith("Handler", StringComparison.Ordinal) .Should() .BeSealed() .GetResult(); diff --git a/tests/Web.Tests.Bunit/Components/Layout/NavMenuTests.cs b/tests/Web.Tests.Bunit/Components/Layout/NavMenuTests.cs index 5f56aad1..5a8b9c53 100644 --- a/tests/Web.Tests.Bunit/Components/Layout/NavMenuTests.cs +++ b/tests/Web.Tests.Bunit/Components/Layout/NavMenuTests.cs @@ -90,7 +90,8 @@ public void NavMenuLoadsThemeFromJsAndAllowsThemeInteraction() // Interact with theme controls cut.Find("select").Change("yellow"); - cut.FindAll("button").Last().Click(); + var buttons = cut.FindAll("button"); + buttons[buttons.Count - 1].Click(); // Assert JS set-calls were triggered cut.WaitForAssertion(() => diff --git a/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs b/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs index c3a6b385..e1867bec 100644 --- a/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs +++ b/tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs @@ -280,7 +280,7 @@ public void BlogIndexConfirmDeleteSendsDeleteCommandAndRefreshesList() var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); cut.Find("button").Click(); - cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); + cut.FindAll("button").Last(button => button.TextContent.Contains("Delete", StringComparison.Ordinal)).Click(); // Assert sender.Received(1).Send(Arg.Is(command => command.Id == postId), Arg.Any()); @@ -309,7 +309,7 @@ public void BlogIndexShowsConcurrencyWarningWhenDeleteFailsWithConcurrency() var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); cut.Find("button").Click(); - cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); + cut.FindAll("button").Last(button => button.TextContent.Contains("Delete", StringComparison.Ordinal)).Click(); // Assert cut.WaitForAssertion(() => diff --git a/tests/Web.Tests.Bunit/Components/Theme/ThemeSelectorTests.cs b/tests/Web.Tests.Bunit/Components/Theme/ThemeSelectorTests.cs index b711953e..3347e4e3 100644 --- a/tests/Web.Tests.Bunit/Components/Theme/ThemeSelectorTests.cs +++ b/tests/Web.Tests.Bunit/Components/Theme/ThemeSelectorTests.cs @@ -273,8 +273,8 @@ public ThemeProviderWithSelectorIntegrationTests() JSInterop.Mode = JSRuntimeMode.Loose; JSInterop.Setup("themeManager.getColor").SetResult("blue"); JSInterop.Setup("themeManager.getBrightness").SetResult("light"); - JSInterop.SetupVoid("themeManager.setColor"); - JSInterop.SetupVoid("themeManager.setBrightness"); + JSInterop.SetupVoid("themeManager.setColor").SetVoidResult(); + JSInterop.SetupVoid("themeManager.setBrightness").SetVoidResult(); } [Fact] @@ -317,7 +317,7 @@ public void ThemeSelectorInsideThemeProviderReceivesCurrentBrightnessViaCascade( public void ColorDropdownChangeInsideThemeProviderCallsSetColorJs() { // Arrange - JSInterop.SetupVoid("themeManager.setColor", "yellow"); + JSInterop.SetupVoid("themeManager.setColor", "yellow").SetVoidResult(); var cut = Render(parameters => parameters .AddChildContent()); @@ -338,7 +338,7 @@ public void BrightnessToggleClickInsideThemeProviderCallsSetBrightnessJs() { // Arrange JSInterop.Setup("themeManager.getBrightness").SetResult("light"); - JSInterop.SetupVoid("themeManager.setBrightness", "dark"); + JSInterop.SetupVoid("themeManager.setBrightness", "dark").SetVoidResult(); var cut = Render(parameters => parameters .AddChildContent()); @@ -359,7 +359,7 @@ public void BrightnessToggleClickInsideThemeProviderUpdatesCurrentBrightnessInCa // Arrange — proves the full toggle pipeline: click → HandleBrightnessChanged → Provider.SetBrightness → CurrentBrightness JSInterop.Setup("themeManager.getColor").SetResult("blue"); JSInterop.Setup("themeManager.getBrightness").SetResult("light"); - JSInterop.SetupVoid("themeManager.setBrightness", "dark"); + JSInterop.SetupVoid("themeManager.setBrightness", "dark").SetVoidResult(); var cut = Render(parameters => parameters .AddChildContent()); @@ -377,7 +377,7 @@ public void BrightnessToggleClickInsideThemeProviderUpdatesCurrentBrightnessInCa public void ColorDropdownChangeInsideThemeProviderUpdatesCurrentColorInCascade() { // Arrange — proves the full color pipeline: change → HandleColorChanged → Provider.SetColor → CurrentColor - JSInterop.SetupVoid("themeManager.setColor", "yellow"); + JSInterop.SetupVoid("themeManager.setColor", "yellow").SetVoidResult(); var cut = Render(parameters => parameters .AddChildContent()); diff --git a/tests/Web.Tests.Bunit/Testing/TestAuthorizationService.cs b/tests/Web.Tests.Bunit/Testing/TestAuthorizationService.cs index 5498263d..9c7f9581 100644 --- a/tests/Web.Tests.Bunit/Testing/TestAuthorizationService.cs +++ b/tests/Web.Tests.Bunit/Testing/TestAuthorizationService.cs @@ -10,6 +10,7 @@ namespace Web.Testing; +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by the test DI container in bUnit fixtures.")] internal sealed class TestAuthorizationService : IAuthorizationService { public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) diff --git a/tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj b/tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj index 491c6e86..ac7b03b8 100644 --- a/tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj +++ b/tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj @@ -7,7 +7,7 @@ false true Web - $(NoWarn);CA2007 + $(NoWarn);CA2007;CA1014 diff --git a/tests/Web.Tests.Integration/Infrastructure/MongoDbFixture.cs b/tests/Web.Tests.Integration/Infrastructure/MongoDbFixture.cs index 46db4538..666973e0 100644 --- a/tests/Web.Tests.Integration/Infrastructure/MongoDbFixture.cs +++ b/tests/Web.Tests.Integration/Infrastructure/MongoDbFixture.cs @@ -16,9 +16,7 @@ namespace Web.Infrastructure; public sealed class MongoDbFixture : IAsyncLifetime { private readonly MongoDbContainer _container = -#pragma warning disable CS0618 - new MongoDbBuilder().Build(); -#pragma warning restore CS0618 + new MongoDbBuilder("mongo").Build(); public string ConnectionString { get; private set; } = string.Empty; diff --git a/tests/Web.Tests.Integration/Infrastructure/RedisFixture.cs b/tests/Web.Tests.Integration/Infrastructure/RedisFixture.cs index a7083e61..c00f4e92 100644 --- a/tests/Web.Tests.Integration/Infrastructure/RedisFixture.cs +++ b/tests/Web.Tests.Integration/Infrastructure/RedisFixture.cs @@ -16,9 +16,7 @@ namespace Web.Infrastructure; public sealed class RedisFixture : IAsyncLifetime { private readonly RedisContainer _container = -#pragma warning disable CS0618 - new RedisBuilder().Build(); -#pragma warning restore CS0618 + new RedisBuilder("redis").Build(); public string ConnectionString { get; private set; } = string.Empty; diff --git a/tests/Web.Tests.Integration/Properties/AssemblyInfo.cs b/tests/Web.Tests.Integration/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..46d94d6f --- /dev/null +++ b/tests/Web.Tests.Integration/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : AssemblyInfo.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Web.Tests.Integration +//======================================================= + +[assembly: CLSCompliant(false)] diff --git a/tests/Web.Tests.Integration/Web.Tests.Integration.csproj b/tests/Web.Tests.Integration/Web.Tests.Integration.csproj index 5a69a298..cb7cb805 100644 --- a/tests/Web.Tests.Integration/Web.Tests.Integration.csproj +++ b/tests/Web.Tests.Integration/Web.Tests.Integration.csproj @@ -7,7 +7,10 @@ false true Web - $(NoWarn);CA2007 + + $(NoWarn);CA2007;CA1711 diff --git a/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs index d3b707b8..56b08e00 100644 --- a/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/DeleteBlogPostHandlerTests.cs @@ -66,7 +66,7 @@ public async Task Handle_ConcurrentDelete_ReturnsConcurrencyErrorCode() var id = ObjectId.GenerateNewId(); var command = new DeleteBlogPostCommand(id); _repo.DeleteAsync(id, Arg.Any()) - .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new Exception())); + .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new InvalidOperationException("stale delete state"))); // Act var result = await _handler.Handle(command, CancellationToken.None); diff --git a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs index b93ab771..d9913836 100644 --- a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs @@ -20,7 +20,7 @@ namespace Web.Handlers; public class EditBlogPostHandlerTests { private readonly IBlogPostRepository _repo = Substitute.For(); - private readonly IBlogPostCacheService _cache = Substitute.For(); + private readonly TestBlogPostCacheService _cache = new(); private readonly IHtmlSanitizer _sanitizer = Substitute.For(); private readonly EditBlogPostHandler _handler; @@ -47,8 +47,8 @@ public async Task HandleEdit_Success_UpdatesPostAndInvalidatesBothCaches() // Assert result.Success.Should().BeTrue(); await _repo.Received(1).UpdateAsync(post, Arg.Any()); - await _cache.Received(1).InvalidateAllAsync(Arg.Any()); - await _cache.Received(1).InvalidateByIdAsync(post.Id, Arg.Any()); + _cache.InvalidateAllCalls.Should().Be(1); + _cache.InvalidatedIds.Should().ContainSingle().Which.Should().Be(post.Id); post.Title.Should().Be("New Title"); post.Content.Should().Be("New Content"); } @@ -155,7 +155,7 @@ public async Task HandleEdit_DifferentNonAdminUser_ReturnsUnauthorized() // Assert result.Failure.Should().BeTrue(); - result.ErrorCode.Should().Be(MyBlog.Domain.Abstractions.ResultErrorCode.Unauthorized); + result.ErrorCode.Should().Be(ResultErrorCode.Unauthorized); result.Error.Should().Contain("not authorized"); } @@ -200,11 +200,7 @@ public async Task HandleGetById_L1CacheHit_ReturnsCachedDtoWithoutRepo() // Arrange var id = ObjectId.GenerateNewId(); var dto = new BlogPostDto(id, "T", "C", string.Empty, "A", string.Empty, [], DateTime.UtcNow, null, false, null); - _cache.GetOrFetchByIdAsync( - Arg.Any(), - Arg.Any>>(), - Arg.Any()) - .Returns(new ValueTask(dto)); + _cache.GetOrFetchByIdAsyncHandler = (_, _, _) => ValueTask.FromResult(dto); // Act var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); @@ -222,15 +218,7 @@ public async Task HandleGetById_CacheMissRepoReturnsNull_ReturnsOkWithNull() // Arrange var id = ObjectId.GenerateNewId(); _repo.GetByIdAsync(id, Arg.Any()).Returns((BlogPost?)null); - _cache.GetOrFetchByIdAsync( - Arg.Any(), - Arg.Any>>(), - Arg.Any()) - .Returns>(ci => - { - var fetch = ci.Arg>>(); - return new ValueTask(fetch().GetAwaiter().GetResult()); - }); + _cache.GetOrFetchByIdAsyncHandler = (_, fetch, _) => new ValueTask(fetch()); // Act var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); @@ -248,15 +236,7 @@ public async Task HandleGetById_CacheMissRepoReturnsPost_MapsToDtoAndPopulatesCa var categoryId = ObjectId.GenerateNewId(); post.AssignCategory(categoryId); _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); - _cache.GetOrFetchByIdAsync( - Arg.Any(), - Arg.Any>>(), - Arg.Any()) - .Returns>(ci => - { - var fetch = ci.Arg>>(); - return new ValueTask(fetch().GetAwaiter().GetResult()); - }); + _cache.GetOrFetchByIdAsyncHandler = (_, fetch, _) => new ValueTask(fetch()); // Act var result = await _handler.Handle(new GetBlogPostByIdQuery(post.Id), CancellationToken.None); @@ -278,7 +258,7 @@ public async Task HandleEdit_ConcurrentUpdate_ReturnsConcurrencyErrorCode() var command = new EditBlogPostCommand(post.Id, "New Title", "New Content", authorId, false); _repo.GetByIdAsync(post.Id, Arg.Any()).Returns(post); _repo.UpdateAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new Exception())); + .ThrowsAsync(new DbUpdateConcurrencyException("conflict", new InvalidOperationException("stale edit state"))); // Act var result = await _handler.Handle(command, CancellationToken.None); @@ -293,11 +273,7 @@ public async Task HandleGetById_CacheServiceThrows_ReturnsFailResult() { // Arrange var id = ObjectId.GenerateNewId(); - _cache.GetOrFetchByIdAsync( - id, - Arg.Any>>(), - Arg.Any()) - .ThrowsAsync(new InvalidOperationException("redis down")); + _cache.GetOrFetchByIdAsyncHandler = (_, _, _) => throw new InvalidOperationException("redis down"); // Act var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); @@ -347,11 +323,7 @@ public async Task HandleGetById_OperationCanceled_Rethrows() { // Arrange var id = ObjectId.GenerateNewId(); - _cache.GetOrFetchByIdAsync( - id, - Arg.Any>>(), - Arg.Any()) - .ThrowsAsync(new OperationCanceledException()); + _cache.GetOrFetchByIdAsyncHandler = (_, _, _) => throw new OperationCanceledException(); // Act Func act = () => _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); @@ -365,11 +337,7 @@ public async Task HandleGetById_UnexpectedException_ReturnsUnexpectedErrorResult { // Arrange var id = ObjectId.GenerateNewId(); - _cache.GetOrFetchByIdAsync( - id, - Arg.Any>>(), - Arg.Any()) - .ThrowsAsync(new TimeoutException("db timeout")); + _cache.GetOrFetchByIdAsyncHandler = (_, _, _) => throw new TimeoutException("db timeout"); // Act var result = await _handler.Handle(new GetBlogPostByIdQuery(id), CancellationToken.None); @@ -378,4 +346,37 @@ public async Task HandleGetById_UnexpectedException_ReturnsUnexpectedErrorResult result.Failure.Should().BeTrue(); result.Error.Should().Be("An unexpected error occurred."); } + + private sealed class TestBlogPostCacheService : IBlogPostCacheService + { + public Func>, CancellationToken, ValueTask> GetOrFetchByIdAsyncHandler { get; set; } = + (_, _, _) => throw new NotSupportedException(); + + public int InvalidateAllCalls { get; private set; } + + public List InvalidatedIds { get; } = []; + + public ValueTask> GetOrFetchAllAsync( + Func>> fetch, + CancellationToken ct = default) => + throw new NotSupportedException(); + + public ValueTask GetOrFetchByIdAsync( + ObjectId id, + Func> fetch, + CancellationToken ct = default) => + GetOrFetchByIdAsyncHandler(id, fetch, ct); + + public Task InvalidateAllAsync(CancellationToken ct = default) + { + InvalidateAllCalls++; + return Task.CompletedTask; + } + + public Task InvalidateByIdAsync(ObjectId id, CancellationToken ct = default) + { + InvalidatedIds.Add(id); + return Task.CompletedTask; + } + } } diff --git a/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs b/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs index 95f218d9..55f5bd21 100644 --- a/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs +++ b/tests/Web.Tests/Handlers/GetBlogPostsHandlerTests.cs @@ -14,7 +14,7 @@ namespace Web.Handlers; public class GetBlogPostsHandlerTests { private readonly IBlogPostRepository _repo = Substitute.For(); - private readonly IBlogPostCacheService _cache = Substitute.For(); + private readonly PassThroughBlogPostCacheService _cache = new(); private readonly GetBlogPostsHandler _handler; public GetBlogPostsHandlerTests() @@ -33,10 +33,7 @@ public async Task Handle_L1CacheHit_ReturnsCachedDataWithoutCallingRepo() { // Arrange var cachedList = MakeDtos(); - _cache.GetOrFetchAllAsync( - Arg.Any>>>(), - Arg.Any()) - .Returns(new ValueTask>(cachedList)); + _cache.GetOrFetchAllAsyncHandler = (_, _) => new ValueTask>(cachedList); // Act var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); @@ -53,10 +50,7 @@ public async Task Handle_L2CacheHit_ReturnsCachedDataWithoutCallingRepo() { // Arrange var cachedList = MakeDtos(); - _cache.GetOrFetchAllAsync( - Arg.Any>>>(), - Arg.Any()) - .Returns(new ValueTask>(cachedList)); + _cache.GetOrFetchAllAsyncHandler = (_, _) => new ValueTask>(cachedList); // Act var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); @@ -78,14 +72,7 @@ public async Task Handle_CacheMiss_CallsRepoAndPopulatesBothCaches() var post2 = BlogPost.Create("T2", "C2", new PostAuthor("", "Test Author", "", [])); _repo.GetAllAsync(Arg.Any()) .Returns(new List { post1, post2 }); - _cache.GetOrFetchAllAsync( - Arg.Any>>>(), - Arg.Any()) - .Returns>>(ci => - { - var fetch = ci.Arg>>>(); - return new ValueTask>(fetch().GetAwaiter().GetResult()); - }); + _cache.GetOrFetchAllAsyncHandler = (fetch, _) => new ValueTask>(fetch()); // Act var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); @@ -103,12 +90,9 @@ public async Task Handle_CacheMiss_CallsRepoAndPopulatesBothCaches() public async Task Handle_RepoThrows_ReturnsFailResult() { // Arrange - _cache.GetOrFetchAllAsync( - Arg.Any>>>(), - Arg.Any()) - .Returns(new ValueTask>( - Task.FromException>( - new InvalidOperationException("db error")))); + _repo.GetAllAsync(Arg.Any()) + .Returns(Task.FromException>( + new InvalidOperationException("db error"))); // Act var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); @@ -122,10 +106,7 @@ public async Task Handle_RepoThrows_ReturnsFailResult() public async Task Handle_CacheServiceThrows_ReturnsFailResult() { // Arrange - _cache.GetOrFetchAllAsync( - Arg.Any>>>(), - Arg.Any()) - .ThrowsAsync(new InvalidOperationException("redis down")); + _cache.GetOrFetchAllAsyncHandler = (_, _) => throw new InvalidOperationException("redis down"); // Act var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); @@ -139,10 +120,7 @@ public async Task Handle_CacheServiceThrows_ReturnsFailResult() public async Task Handle_OperationCanceled_Rethrows() { // Arrange - _cache.GetOrFetchAllAsync( - Arg.Any>>>(), - Arg.Any()) - .ThrowsAsync(new OperationCanceledException()); + _cache.GetOrFetchAllAsyncHandler = (_, _) => throw new OperationCanceledException(); // Act Func act = () => _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); @@ -155,10 +133,7 @@ public async Task Handle_OperationCanceled_Rethrows() public async Task Handle_UnexpectedException_ReturnsUnexpectedErrorResult() { // Arrange - _cache.GetOrFetchAllAsync( - Arg.Any>>>(), - Arg.Any()) - .ThrowsAsync(new TimeoutException("db timeout")); + _cache.GetOrFetchAllAsyncHandler = (_, _) => throw new TimeoutException("db timeout"); // Act var result = await _handler.Handle(new GetBlogPostsQuery(), CancellationToken.None); @@ -167,4 +142,25 @@ public async Task Handle_UnexpectedException_ReturnsUnexpectedErrorResult() result.Failure.Should().BeTrue(); result.Error.Should().Be("An unexpected error occurred."); } + + private sealed class PassThroughBlogPostCacheService : IBlogPostCacheService + { + public Func>>, CancellationToken, ValueTask>> GetOrFetchAllAsyncHandler { get; set; } = + (fetch, _) => new ValueTask>(fetch()); + + public ValueTask> GetOrFetchAllAsync( + Func>> fetch, + CancellationToken ct = default) => + GetOrFetchAllAsyncHandler(fetch, ct); + + public ValueTask GetOrFetchByIdAsync( + ObjectId id, + Func> fetch, + CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task InvalidateAllAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task InvalidateByIdAsync(ObjectId id, CancellationToken ct = default) => Task.CompletedTask; + } } diff --git a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs index 6b89f6a0..382c1627 100644 --- a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs +++ b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs @@ -17,14 +17,12 @@ public class UserManagementHandlerTests private readonly IConfiguration _config = Substitute.For(); private readonly IHttpClientFactory _httpFactory = Substitute.For(); - private readonly IUserManagementCacheService _cache; private readonly UserManagementHandler _handler; public UserManagementHandlerTests() { _config["Auth0:ManagementApiDomain"].Returns((string?)null); - _cache = BuildPassThroughCache(); - _handler = new UserManagementHandler(_config, _httpFactory, _cache); + _handler = new UserManagementHandler(_config, _httpFactory, BuildPassThroughCache()); } // ── Domain missing ────────────────────────────────────────────────────────────── @@ -378,21 +376,8 @@ public async Task HandleGetAvailableRolesBlankOrMissingAccessTokenReturnsExplici // ── helpers ─────────────────────────────────────────────────────────────────────────────── - private static IUserManagementCacheService BuildPassThroughCache() - { - var cache = Substitute.For(); - cache.GetOrFetchUsersAsync( - Arg.Any>>>(), - Arg.Any()) - .Returns(ci => new ValueTask>( - ci.Arg>>>()())); - cache.GetOrFetchRolesAsync( - Arg.Any>>>(), - Arg.Any()) - .Returns(ci => new ValueTask>( - ci.Arg>>>()())); - return cache; - } + private static PassThroughUserManagementCacheService BuildPassThroughCache() + => new PassThroughUserManagementCacheService(); private static UserManagementHandler BuildHandlerWithPrimaryKeys(IHttpClientFactory httpFactory, string audience) { @@ -467,6 +452,23 @@ private sealed class StaticHttpClientFactory(HttpClient httpClient) : IHttpClien public HttpClient CreateClient(string name) => httpClient; } + private sealed class PassThroughUserManagementCacheService : IUserManagementCacheService + { + public async ValueTask> GetOrFetchUsersAsync( + Func>> fetch, + CancellationToken _ = default) => + await fetch().ConfigureAwait(false); + + public async ValueTask> GetOrFetchRolesAsync( + Func>> fetch, + CancellationToken _ = default) => + await fetch().ConfigureAwait(false); + + public Task InvalidateUsersAsync(CancellationToken _ = default) => Task.CompletedTask; + + public Task InvalidateRolesAsync(CancellationToken _ = default) => Task.CompletedTask; + } + private sealed class RecordingTokenHttpHandler(string responseJson) : HttpMessageHandler { public Uri? LastRequestUri { get; private set; } diff --git a/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs index 431dd3bc..af93107e 100644 --- a/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs +++ b/tests/Web.Tests/Infrastructure/Caching/BlogPostCacheServiceTests.cs @@ -11,7 +11,7 @@ namespace Web.Infrastructure.Caching; -public class BlogPostCacheServiceTests : IDisposable +public sealed class BlogPostCacheServiceTests : IDisposable { private readonly MemoryCache _realLocalCache = new(new MemoryCacheOptions()); private readonly IDistributedCache _distributedCache = Substitute.For(); @@ -22,7 +22,10 @@ public BlogPostCacheServiceTests() _sut = new BlogPostCacheService(_realLocalCache, _distributedCache); } - public void Dispose() => _realLocalCache.Dispose(); + public void Dispose() + { + _realLocalCache.Dispose(); + } private static readonly JsonSerializerOptions JsonOpts = BlogPostCacheService.JsonOpts; diff --git a/tests/Web.Tests/Infrastructure/Caching/UserManagementCacheServiceTests.cs b/tests/Web.Tests/Infrastructure/Caching/UserManagementCacheServiceTests.cs index ac12f848..7548bdf8 100644 --- a/tests/Web.Tests/Infrastructure/Caching/UserManagementCacheServiceTests.cs +++ b/tests/Web.Tests/Infrastructure/Caching/UserManagementCacheServiceTests.cs @@ -13,7 +13,7 @@ namespace Web.Infrastructure.Caching; -public class UserManagementCacheServiceTests : IDisposable +public sealed class UserManagementCacheServiceTests : IDisposable { private readonly MemoryCache _localCache = new(new MemoryCacheOptions()); private readonly IDistributedCache _distributedCache = Substitute.For(); @@ -26,7 +26,10 @@ public UserManagementCacheServiceTests() _sut = new UserManagementCacheService(_localCache, _distributedCache); } - public void Dispose() => _localCache.Dispose(); + public void Dispose() + { + _localCache.Dispose(); + } private static List MakeUsers() => [ diff --git a/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs b/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs new file mode 100644 index 00000000..e16204a7 --- /dev/null +++ b/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs @@ -0,0 +1,58 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : LocalDiskFileStorageTests.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Web.Tests +//======================================================= + +using System.Text; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +using MyBlog.Web.Infrastructure.FileStorage; + +namespace Web.Infrastructure.FileStorage; + +public sealed class LocalDiskFileStorageTests : IDisposable +{ + private readonly string _webRootPath = Path.Combine(Path.GetTempPath(), $"myblog-localdiskfilestorage-{Guid.NewGuid():N}"); + private readonly IWebHostEnvironment _webHostEnvironment = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + + public LocalDiskFileStorageTests() + { + _webHostEnvironment.WebRootPath.Returns(_webRootPath); + } + + public void Dispose() + { + if (Directory.Exists(_webRootPath)) + { + Directory.Delete(_webRootPath, recursive: true); + } + } + + [Fact] + public async Task AddFileAsync_WithAllowedImage_WritesFileUnderUploadsAndReturnsStoredName() + { + // Arrange + var sut = new LocalDiskFileStorage(_webHostEnvironment, _logger); + await using var content = new MemoryStream(Encoding.UTF8.GetBytes("image payload")); + var file = new FileData( + content, + new FileMetaData("cover.png", "image/png", DateTime.UtcNow)); + + // Act + var storedName = await sut.AddFileAsync(file); + + // Assert + storedName.Should().EndWith(".PNG"); + var savedPath = Path.Combine(_webRootPath, "uploads", storedName); + File.Exists(savedPath).Should().BeTrue(); + var savedContents = await File.ReadAllTextAsync(savedPath, TestContext.Current.CancellationToken); + savedContents.Should().Be("image payload"); + } +} diff --git a/tests/Web.Tests/Properties/AssemblyInfo.cs b/tests/Web.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..67ddb1a9 --- /dev/null +++ b/tests/Web.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : AssemblyInfo.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Web.Tests +//======================================================= + +[assembly: CLSCompliant(false)]