From f1c002bbf68a566b7635000bd713da95ab132c20 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Wed, 27 May 2026 22:16:28 -0700 Subject: [PATCH 01/16] fix(#402): complete analyzer-warning cleanup on authorized test slice - MongoClient disposals via 'using' directive in AppHost integration tests - HttpClientHandler refactoring in MongoSeedDataIntegrationTests - Pass-through fake cache services in handler tests (no NSubstitute seams) - Test class sealing and Dispose expansion for CA1001 compliance - StringComparison.Ordinal in RazorSmokeTests for CA1307 compliance - NavMenuTests button access refactored away from LINQ Last() on IReadOnlyList - Zero warnings in Release build across entire solution Closes #402 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MongoSeedDataIntegrationTests.cs | 15 ++--- .../MongoShowStatsIntegrationTests.cs | 10 +-- .../Components/Layout/NavMenuTests.cs | 3 +- .../Components/RazorSmokeTests.cs | 4 +- .../Handlers/GetBlogPostsHandlerTests.cs | 66 +++++++++---------- .../Handlers/UserManagementHandlerTests.cs | 38 ++++++----- .../Caching/BlogPostCacheServiceTests.cs | 7 +- .../UserManagementCacheServiceTests.cs | 7 +- 8 files changed, 77 insertions(+), 73 deletions(-) diff --git a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs index e9c4b74e..7dc01b54 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(); @@ -273,11 +273,10 @@ public async Task SeedMyBlogData_Makes_Seeded_Posts_Visible_On_The_Blog_Page() var annotation = GetAnnotation(); var endpoint = fixture.App.GetEndpoint("web", "https"); - using var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, - }; - using var webClient = new HttpClient(handler) { BaseAddress = endpoint }; + using var handler = new HttpClientHandler(); + handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + using var webClient = new HttpClient(handler); + webClient.BaseAddress = endpoint; await WaitForWebReadyAsync(webClient); // Act 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/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/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() => [ From b0749d646c28b862b2effffecf7a72ad977b2122 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Wed, 27 May 2026 22:29:01 -0700 Subject: [PATCH 02/16] fix(#402): eliminate CS0618 pragma suppressions in test projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MongoDbFixture: use MongoDbBuilder("mongo") to avoid obsolete parameterless ctor - RedisFixture: use RedisBuilder("redis") to avoid obsolete parameterless ctor - EnvVarTests: migrate GetEnvironmentVariableValuesAsync → ExecutionConfigurationBuilder API per Aspire 13.x guidance; update assertions to work with IEnumerable> returned by resolvedConfig.EnvironmentVariables All #pragma warning disable CS0618 blocks removed from tests/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/AppHost.Tests/EnvVarTests.cs | 29 ++++++++++++------- .../Infrastructure/MongoDbFixture.cs | 4 +-- .../Infrastructure/RedisFixture.cs | 4 +-- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/tests/AppHost.Tests/EnvVarTests.cs b/tests/AppHost.Tests/EnvVarTests.cs index a201fa7f..0e8bfe1f 100644 --- a/tests/AppHost.Tests/EnvVarTests.cs +++ b/tests/AppHost.Tests/EnvVarTests.cs @@ -8,9 +8,12 @@ // ============================================= using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; + namespace AppHost.Tests; /// @@ -35,13 +38,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); // Assert - envVars.Should().ContainKey("ConnectionStrings__myblog"); + resolvedConfig.EnvironmentVariables + .Select(kvp => kvp.Key) + .Should().Contain("ConnectionStrings__myblog"); } [Fact] @@ -61,12 +67,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); // Assert - envVars.Should().ContainKey("ConnectionStrings__redis"); + resolvedConfig.EnvironmentVariables + .Select(kvp => kvp.Key) + .Should().Contain("ConnectionStrings__redis"); } } 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; From 00562ddf6e43a3eea9bbbb89af6084a9dedeaffd Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 09:27:53 -0700 Subject: [PATCH 03/16] fix: change Program class access modifier to internal and add AssemblyInfo.cs --- src/AppHost/AppHost.cs | 2 +- src/AppHost/Properties/AssemblyInfo.cs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 src/AppHost/Properties/AssemblyInfo.cs 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)] From 1323715981036c464682d7db6dd5d89c150fc85f Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 09:28:56 -0700 Subject: [PATCH 04/16] refactor: update theme toggle tests to use ThemeToggleTestRuntime methods and remove unnecessary skips --- tests/AppHost.Tests/EnvVarTests.cs | 5 +- .../Tests/Layout/LayoutThemeToggleTests.cs | 78 ++++++++----------- .../Layout/ThemeToggleInteractionTests.cs | 55 +++++++------ 3 files changed, 63 insertions(+), 75 deletions(-) diff --git a/tests/AppHost.Tests/EnvVarTests.cs b/tests/AppHost.Tests/EnvVarTests.cs index 0e8bfe1f..c7ff29d1 100644 --- a/tests/AppHost.Tests/EnvVarTests.cs +++ b/tests/AppHost.Tests/EnvVarTests.cs @@ -8,7 +8,6 @@ // ============================================= using Aspire.Hosting; -using Aspire.Hosting.ApplicationModel; using FluentAssertions; @@ -42,7 +41,7 @@ public async Task WebResourceHasMongoDbConnectionString() var resolvedConfig = await ExecutionConfigurationBuilder .Create(webResource) .WithEnvironmentVariablesConfig() - .BuildAsync(executionContext, NullLogger.Instance); + .BuildAsync(executionContext, NullLogger.Instance, TestContext.Current.CancellationToken); // Assert resolvedConfig.EnvironmentVariables @@ -71,7 +70,7 @@ public async Task WebResourceHasRedisConnectionString() var resolvedConfig = await ExecutionConfigurationBuilder .Create(webResource) .WithEnvironmentVariablesConfig() - .BuildAsync(executionContext, NullLogger.Instance); + .BuildAsync(executionContext, NullLogger.Instance, TestContext.Current.CancellationToken); // Assert resolvedConfig.EnvironmentVariables diff --git a/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs b/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs index c119c202..494d1f4f 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,26 @@ 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 becameInteractive = await ThemeToggleTestRuntime.WaitForThemeReadyAsync(page, toggleButton); + if (!becameInteractive) { - 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 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 reload/bootstrap flow seeded with '{initialBrightness}'. Observed after reload: {ThemeToggleTestRuntime.DescribeSignals(blockedSignals)}. 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}", + var startingSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); + + // 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 +74,25 @@ 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 toggledToExpectedState = await ThemeToggleTestRuntime.WaitForThemeStateAsync( + page, + toggleButton, + expectedBrightness, + expectedDarkClass); + + var endingSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); + if (!toggledToExpectedState && !endingSignals.IsTrustworthyInteractiveState()) { - 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..65bda1af 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,25 @@ 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); + var toggledToDark = await ThemeToggleTestRuntime.WaitForThemeStateAsync(page, toggleButton, expectedBrightness: "dark", expectedDarkClass: true); if (!toggledToDark) { - 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 blockedSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); + var assetDiagnostics = await ThemeToggleTestRuntime.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: {ThemeToggleTestRuntime.DescribeSignals(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); } - var themeSignalsBeforeNavigation = await ReadThemeSignalsAsync(page, toggleButton); + var themeSignalsBeforeNavigation = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); var blogPostsLink = page.Locator("nav[aria-label=\"Main navigation\"] a[href=\"blog\"]").First; await blogPostsLink.ClickAsync(); @@ -72,15 +72,15 @@ 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); + var persistedOnBlogPage = await ThemeToggleTestRuntime.WaitForThemeStateAsync(page, blogToggleButton, expectedBrightness: "dark", expectedDarkClass: true); if (!persistedOnBlogPage) { - 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 blockedSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, blogToggleButton); + 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(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); } - var themeSignalsAfterNavigation = await ReadThemeSignalsAsync(page, blogToggleButton); + var themeSignalsAfterNavigation = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, blogToggleButton); var headingText = await blogHeading.TextContentAsync(); // Assert @@ -97,7 +97,12 @@ await page.EmulateMediaAsync(new() }); } - private static async Task WaitForThemeReadyAsync(IPage page, ILocator toggleButton, TimeSpan? timeout = null) +} + + +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)); @@ -115,7 +120,7 @@ private static async Task WaitForThemeReadyAsync(IPage page, ILocator togg return false; } - private static async Task WaitForThemeStateAsync( + internal static async Task WaitForThemeStateAsync( IPage page, ILocator toggleButton, string expectedBrightness, @@ -141,7 +146,7 @@ private static async Task WaitForThemeStateAsync( return false; } - private static async Task ReadThemeSignalsAsync(IPage page, ILocator toggleButton) + 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')"); @@ -165,7 +170,7 @@ private static async Task ReadThemeSignalsAsync(IPage page, ILocat sawBlazorScriptResource); } - private static async Task ReadAssetFetchDiagnosticsAsync(IPage page) + internal static async Task ReadAssetFetchDiagnosticsAsync(IPage page) { var diagnostics = await page.EvaluateAsync( """ @@ -219,7 +224,7 @@ private static async Task ReadAssetFetchDiagnosticsAsync(IPage page) return string.IsNullOrWhiteSpace(diagnostics) ? "[]" : diagnostics; } - private static string DescribeSignals(ThemeSignals signals) + internal static string DescribeSignals(ThemeSignals signals) { var darkClass = signals.HasDarkClass ? "true" : "false"; var themeManager = signals.HasThemeManager ? "present" : "missing"; @@ -230,7 +235,7 @@ private static string DescribeSignals(ThemeSignals signals) 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( + internal sealed record ThemeSignals( bool HasDarkClass, string? StoredBrightness, string? StoredColor, @@ -241,18 +246,18 @@ private sealed record ThemeSignals( bool SawThemeScriptResource, bool SawBlazorScriptResource) { - public bool IsTrustworthyInteractiveState() => + internal bool IsTrustworthyInteractiveState() => (string.Equals(ReadinessMarker, "true", StringComparison.Ordinal) || (HasThemeManager && HasBlazor)) && !string.IsNullOrWhiteSpace(AriaLabel); } - private sealed class BrowserRuntimeDiagnostics + internal sealed class BrowserRuntimeDiagnostics { private readonly List _events = []; private readonly object _gate = new(); - public static BrowserRuntimeDiagnostics Attach(IPage page) + internal static BrowserRuntimeDiagnostics Attach(IPage page) { var diagnostics = new BrowserRuntimeDiagnostics(); @@ -263,7 +268,7 @@ public static BrowserRuntimeDiagnostics Attach(IPage page) return diagnostics; } - public string Describe() + internal string Describe() { lock (_gate) { From 48bc201b4663e124a8be0e89ecc0c3c4001c9242 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 09:29:20 -0700 Subject: [PATCH 05/16] fix: update JSInterop setup in ThemeProvider tests and suppress performance warning in TestAuthorizationService --- .../Components/Theme/ThemeSelectorTests.cs | 12 ++++++------ .../Testing/TestAuthorizationService.cs | 1 + tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) 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 From ec8ea8eb0256d5537586a7e6cd55e632d7518008 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 09:29:28 -0700 Subject: [PATCH 06/16] fix: update DeleteBlogPostHandlerTests and EditBlogPostHandlerTests for concurrency exceptions and refactor cache service --- .../Handlers/DeleteBlogPostHandlerTests.cs | 2 +- .../Handlers/EditBlogPostHandlerTests.cs | 85 ++++++++++--------- tests/Web.Tests/Properties/AssemblyInfo.cs | 10 +++ 3 files changed, 54 insertions(+), 43 deletions(-) create mode 100644 tests/Web.Tests/Properties/AssemblyInfo.cs 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..b7e3d71d 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"); } @@ -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/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)] From 910870cce44e5d5cbc755bfe22db0e26128602d4 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 09:29:40 -0700 Subject: [PATCH 07/16] fix: extend NoWarn in Architecture.Tests.csproj and specify StringComparison in type checks --- tests/Architecture.Tests/Architecture.Tests.csproj | 2 +- tests/Architecture.Tests/DomainLayerTests.cs | 2 +- tests/Architecture.Tests/VsaLayerTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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(); From 7925a3997c44f43926cbcbf959061776850dd926 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 09:30:13 -0700 Subject: [PATCH 08/16] fix: correct formatting of ThemeToggleTestRuntime class definition --- tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs index 65bda1af..45fbabac 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs @@ -101,7 +101,7 @@ await page.EmulateMediaAsync(new() 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)); From 75214e7af40c3d7a704e974bb54ce165ef7dc96f Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 09:30:55 -0700 Subject: [PATCH 09/16] refactor: optimize async file handling in LocalDiskFileStorage and ensure proper disposal of FileStream --- .../Components/Theme/ThemeProvider.razor.cs | 38 +++++++--- src/Web/Data/MongoDbBlogPostRepository.cs | 60 +++++++++------ src/Web/Data/MongoDbCategoryRepository.cs | 73 ++++++++++++------- .../FileStorage/LocalDiskFileStorage.cs | 15 +++- src/Web/Security/RoleClaimsHelper.cs | 2 +- 5 files changed, 124 insertions(+), 64 deletions(-) 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..edd86968 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[0] == '[') { try { From 495f034483a96f50ae9dbdbff5fd0b1d73a79712 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 10:19:15 -0700 Subject: [PATCH 10/16] fix: update error code reference in HandleEdit_NotFound test for consistency --- tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs index b7e3d71d..d9913836 100644 --- a/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs +++ b/tests/Web.Tests/Handlers/EditBlogPostHandlerTests.cs @@ -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"); } From b24c97b059890f8383904653f474740d4c01d816 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 10:19:26 -0700 Subject: [PATCH 11/16] feat: add AssemblyInfo.cs and update NoWarn in Web.Tests.Integration.csproj for clarity --- tests/Web.Tests.Integration/Properties/AssemblyInfo.cs | 10 ++++++++++ .../Web.Tests.Integration/Web.Tests.Integration.csproj | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 tests/Web.Tests.Integration/Properties/AssemblyInfo.cs 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 From 89fe4de70af78e9c040b3866366dda88b8936bd6 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 13:27:08 -0700 Subject: [PATCH 12/16] fix: apply PR 403 review follow-ups --- src/Web/Security/RoleClaimsHelper.cs | 2 +- .../MongoSeedDataIntegrationTests.cs | 9 +- .../Layout/ThemeToggleInteractionTests.cs | 189 ----------------- .../Tests/Layout/ThemeToggleTestRuntime.cs | 198 ++++++++++++++++++ .../FileStorage/LocalDiskFileStorageTests.cs | 58 +++++ 5 files changed, 262 insertions(+), 194 deletions(-) create mode 100644 tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs create mode 100644 tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs diff --git a/src/Web/Security/RoleClaimsHelper.cs b/src/Web/Security/RoleClaimsHelper.cs index edd86968..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[0] == '[') + if (trimmed.StartsWith('[')) { try { diff --git a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs index 7dc01b54..87987572 100644 --- a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs @@ -273,10 +273,11 @@ public async Task SeedMyBlogData_Makes_Seeded_Posts_Visible_On_The_Blog_Page() var annotation = GetAnnotation(); var endpoint = fixture.App.GetEndpoint("web", "https"); - using var handler = new HttpClientHandler(); - handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; - using var webClient = new HttpClient(handler); - webClient.BaseAddress = endpoint; + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }; + using var webClient = new HttpClient(handler) { BaseAddress = endpoint }; await WaitForWebReadyAsync(webClient); // Act diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs index 45fbabac..e9e0bff5 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs @@ -98,192 +98,3 @@ await page.EmulateMediaAsync(new() } } - - -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)); - - 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; - } - - 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 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/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs new file mode 100644 index 00000000..7eecae54 --- /dev/null +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs @@ -0,0 +1,198 @@ +//======================================================= +//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)); + + 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; + } + + 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 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/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs b/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs new file mode 100644 index 00000000..813b660f --- /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"); + } +} From 2513b932d8b545a8cee337be0e2f91335375215d Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 13:57:20 -0700 Subject: [PATCH 13/16] style: normalize formatting in test files --- .../Tests/Layout/ThemeToggleTestRuntime.cs | 274 +++++++++--------- .../FileStorage/LocalDiskFileStorageTests.cs | 74 ++--- 2 files changed, 174 insertions(+), 174 deletions(-) diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs index 7eecae54..833ba223 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs @@ -11,78 +11,78 @@ 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)); - - 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; - } - - 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( - """ + 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)); + + 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; + } + + 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', @@ -130,69 +130,69 @@ internal static async Task ReadAssetFetchDiagnosticsAsync(IPage page) } """); - 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 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); - } - } - } + 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 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/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs b/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs index 813b660f..00bf6a22 100644 --- a/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs +++ b/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs @@ -18,41 +18,41 @@ 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"); - } + 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"); + } } From 8fdfece84cf6e496aafe2080d76420a31aab1ec1 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 14:01:14 -0700 Subject: [PATCH 14/16] style: fix test file indentation --- .../Tests/Layout/ThemeToggleTestRuntime.cs | 274 +++++++++--------- .../FileStorage/LocalDiskFileStorageTests.cs | 74 ++--- 2 files changed, 174 insertions(+), 174 deletions(-) diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs index 833ba223..47a2e46a 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs @@ -11,78 +11,78 @@ 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)); - - 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; - } - - 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( - """ + 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)); + + 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; + } + + 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', @@ -130,69 +130,69 @@ internal static async Task ReadAssetFetchDiagnosticsAsync(IPage page) } """); - 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 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); - } - } - } + 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 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/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs b/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs index 00bf6a22..e16204a7 100644 --- a/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs +++ b/tests/Web.Tests/Infrastructure/FileStorage/LocalDiskFileStorageTests.cs @@ -18,41 +18,41 @@ 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"); - } + 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"); + } } From 5b4426940d3852dcb87ae10265e306145ae73008 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 14:21:27 -0700 Subject: [PATCH 15/16] test: tighten AppHost theme runtime assertions --- .../Tests/Layout/LayoutThemeToggleTests.cs | 20 +++++++++-------- .../Layout/ThemeToggleInteractionTests.cs | 22 +++++++++++-------- .../Tests/Layout/ThemeToggleTestRuntime.cs | 21 +++++++++++------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs b/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs index 494d1f4f..59731f8a 100644 --- a/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs @@ -51,16 +51,18 @@ await page.EvaluateAsync( var toggleButton = page.Locator("button[aria-label*=\"Toggle dark mode\"]").First; await toggleButton.WaitForAsync(); - var becameInteractive = await ThemeToggleTestRuntime.WaitForThemeReadyAsync(page, toggleButton); - if (!becameInteractive) + var initialState = await ThemeToggleTestRuntime.WaitForThemeStateAsync( + page, + toggleButton, + initialBrightness, + initialHasDarkClass); + var startingSignals = initialState.Signals; + if (!initialState.MatchedExpectedState && !startingSignals.IsTrustworthyInteractiveState()) { - 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 reload/bootstrap flow seeded with '{initialBrightness}'. Observed after reload: {ThemeToggleTestRuntime.DescribeSignals(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); + 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}."); } - var startingSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); - // Assert startingSignals.HasDarkClass.Should().Be(initialHasDarkClass, because: "the seeded reload/bootstrap path should restore the requested html dark class before interaction"); @@ -74,14 +76,14 @@ await page.EvaluateAsync( var expectedBrightness = initialBrightness == "light" ? "dark" : "light"; var expectedDarkClass = expectedBrightness == "dark"; - var toggledToExpectedState = await ThemeToggleTestRuntime.WaitForThemeStateAsync( + var toggledState = await ThemeToggleTestRuntime.WaitForThemeStateAsync( page, toggleButton, expectedBrightness, expectedDarkClass); - var endingSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); - if (!toggledToExpectedState && !endingSignals.IsTrustworthyInteractiveState()) + var endingSignals = toggledState.Signals; + if (!toggledState.MatchedExpectedState && !endingSignals.IsTrustworthyInteractiveState()) { 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}."); diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs index e9e0bff5..3e4732c1 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs @@ -51,15 +51,20 @@ await page.EmulateMediaAsync(new() await toggleButton.ClickAsync(); - var toggledToDark = await ThemeToggleTestRuntime.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 && !themeSignalsBeforeNavigation.IsTrustworthyInteractiveState()) { - var blockedSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, toggleButton); var assetDiagnostics = await ThemeToggleTestRuntime.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: {ThemeToggleTestRuntime.DescribeSignals(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); + 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 ThemeToggleTestRuntime.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(); @@ -73,14 +78,13 @@ await page.EmulateMediaAsync(new() await blogToggleButton.WaitForAsync(); var persistedOnBlogPage = await ThemeToggleTestRuntime.WaitForThemeStateAsync(page, blogToggleButton, expectedBrightness: "dark", expectedDarkClass: true); - if (!persistedOnBlogPage) + var themeSignalsAfterNavigation = persistedOnBlogPage.Signals; + if (!persistedOnBlogPage.MatchedExpectedState && !themeSignalsAfterNavigation.IsTrustworthyInteractiveState()) { - var blockedSignals = await ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, blogToggleButton); 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(blockedSignals)}. Browser diagnostics: {runtimeDiagnostics.Describe()}. Asset fetch diagnostics: {assetDiagnostics}."); + 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 ThemeToggleTestRuntime.ReadThemeSignalsAsync(page, blogToggleButton); var headingText = await blogHeading.TextContentAsync(); // Assert diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs index 47a2e46a..8374a825 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs @@ -29,7 +29,7 @@ internal static async Task WaitForThemeReadyAsync(IPage page, ILocator tog return false; } - internal static async Task WaitForThemeStateAsync( + internal static async Task WaitForThemeStateAsync( IPage page, ILocator toggleButton, string expectedBrightness, @@ -37,22 +37,25 @@ internal static async Task WaitForThemeStateAsync( TimeSpan? timeout = null) { var deadline = DateTime.UtcNow.Add(timeout ?? TimeSpan.FromSeconds(10)); + ThemeSignals? lastSignals = null; 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)) + lastSignals = await ReadThemeSignalsAsync(page, toggleButton); + if (lastSignals.IsTrustworthyInteractiveState() + && lastSignals.HasDarkClass == expectedDarkClass + && string.Equals(lastSignals.StoredBrightness, expectedBrightness, StringComparison.Ordinal) + && (lastSignals.AriaLabel?.Contains($"currently {expectedBrightness}", StringComparison.Ordinal) ?? false)) { - return true; + return new(lastSignals, true); } await Task.Delay(250); } - return false; + lastSignals ??= await ReadThemeSignalsAsync(page, toggleButton); + + return new(lastSignals, false); } internal static async Task ReadThemeSignalsAsync(IPage page, ILocator toggleButton) @@ -161,6 +164,8 @@ internal bool IsTrustworthyInteractiveState() => && !string.IsNullOrWhiteSpace(AriaLabel); } + internal sealed record ThemeStateWaitResult(ThemeSignals Signals, bool MatchedExpectedState); + internal sealed class BrowserRuntimeDiagnostics { private readonly List _events = []; From e16d3841c932e6c607e81b9a191b0885fd9ed941 Mon Sep 17 00:00:00 2001 From: mpaulosky Date: Thu, 28 May 2026 14:31:14 -0700 Subject: [PATCH 16/16] test: fail after AppHost theme trust loss --- .../Tests/Layout/LayoutThemeToggleTests.cs | 6 +- .../Layout/ThemeToggleInteractionTests.cs | 8 +- .../Tests/Layout/ThemeToggleTestRuntime.cs | 78 ++++++++++--------- 3 files changed, 52 insertions(+), 40 deletions(-) diff --git a/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs b/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs index 59731f8a..828efcb7 100644 --- a/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/LayoutThemeToggleTests.cs @@ -57,7 +57,7 @@ await page.EvaluateAsync( initialBrightness, initialHasDarkClass); var startingSignals = initialState.Signals; - if (!initialState.MatchedExpectedState && !startingSignals.IsTrustworthyInteractiveState()) + if (!initialState.MatchedExpectedState && !initialState.SawTrustworthyInteractiveState) { 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}."); @@ -83,7 +83,9 @@ await page.EvaluateAsync( expectedDarkClass); var endingSignals = toggledState.Signals; - if (!toggledState.MatchedExpectedState && !endingSignals.IsTrustworthyInteractiveState()) + if (!toggledState.MatchedExpectedState + && !toggledState.SawTrustworthyInteractiveState + && !initialState.SawTrustworthyInteractiveState) { 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}."); diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs index 3e4732c1..a6528e2a 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleInteractionTests.cs @@ -53,7 +53,9 @@ await page.EmulateMediaAsync(new() var toggledState = await ThemeToggleTestRuntime.WaitForThemeStateAsync(page, toggleButton, expectedBrightness: "dark", expectedDarkClass: true); var themeSignalsBeforeNavigation = toggledState.Signals; - if (!toggledState.MatchedExpectedState && !themeSignalsBeforeNavigation.IsTrustworthyInteractiveState()) + if (!toggledState.MatchedExpectedState + && !toggledState.SawTrustworthyInteractiveState + && !becameInteractive) { 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}."); @@ -79,7 +81,9 @@ await page.EmulateMediaAsync(new() var persistedOnBlogPage = await ThemeToggleTestRuntime.WaitForThemeStateAsync(page, blogToggleButton, expectedBrightness: "dark", expectedDarkClass: true); var themeSignalsAfterNavigation = persistedOnBlogPage.Signals; - if (!persistedOnBlogPage.MatchedExpectedState && !themeSignalsAfterNavigation.IsTrustworthyInteractiveState()) + if (!persistedOnBlogPage.MatchedExpectedState + && !persistedOnBlogPage.SawTrustworthyInteractiveState + && !becameInteractive) { 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}."); diff --git a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs index 8374a825..d9829550 100644 --- a/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs +++ b/tests/AppHost.Tests/Tests/Layout/ThemeToggleTestRuntime.cs @@ -30,32 +30,38 @@ internal static async Task WaitForThemeReadyAsync(IPage page, ILocator tog } internal static async Task WaitForThemeStateAsync( - IPage page, - ILocator toggleButton, - string expectedBrightness, - bool expectedDarkClass, - TimeSpan? timeout = null) + 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() - && lastSignals.HasDarkClass == expectedDarkClass - && string.Equals(lastSignals.StoredBrightness, expectedBrightness, StringComparison.Ordinal) - && (lastSignals.AriaLabel?.Contains($"currently {expectedBrightness}", StringComparison.Ordinal) ?? false)) + if (lastSignals.IsTrustworthyInteractiveState()) { - return new(lastSignals, true); + 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); + return new(lastSignals, false, sawTrustworthyInteractiveState); } internal static async Task ReadThemeSignalsAsync(IPage page, ILocator toggleButton) @@ -71,21 +77,21 @@ internal static async Task ReadThemeSignalsAsync(IPage page, ILoca 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); + 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', @@ -148,23 +154,23 @@ internal static string DescribeSignals(ThemeSignals signals) } internal sealed record ThemeSignals( - bool HasDarkClass, - string? StoredBrightness, - string? StoredColor, - string? AriaLabel, - string? ReadinessMarker, - bool HasThemeManager, - bool HasBlazor, - bool SawThemeScriptResource, - bool SawBlazorScriptResource) + 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); + (string.Equals(ReadinessMarker, "true", StringComparison.Ordinal) + || (HasThemeManager && HasBlazor)) + && !string.IsNullOrWhiteSpace(AriaLabel); } - internal sealed record ThemeStateWaitResult(ThemeSignals Signals, bool MatchedExpectedState); + internal sealed record ThemeStateWaitResult(ThemeSignals Signals, bool MatchedExpectedState, bool SawTrustworthyInteractiveState); internal sealed class BrowserRuntimeDiagnostics { @@ -187,8 +193,8 @@ internal string Describe() lock (_gate) { return _events.Count == 0 - ? "no console, pageerror, or requestfailed events captured" - : string.Join(" | ", _events.TakeLast(6)); + ? "no console, pageerror, or requestfailed events captured" + : string.Join(" | ", _events.TakeLast(6)); } }