From 98412da5825f8eb2ba45f61871e7d9ecf144faba Mon Sep 17 00:00:00 2001 From: Boromir Date: Fri, 8 May 2026 09:58:39 -0700 Subject: [PATCH 1/2] feat(AppHost): add WithShowStatsCommand for local dev stats (#261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WithShowStatsCommand private method to MongoDbResourceBuilderExtensions - Command name: 'show-myblog-stats', display: '📊 Show MyBlog Stats' - Returns Markdown table of collection document counts via CommandResultData with Format = Markdown and DisplayImmediately = true - Empty DB returns '*(no collections found)*' row with Success = true - Shared _clearMutex prevents concurrent operations (non-blocking WaitAsync(0)) - Skips system.* collections consistent with clear command - UpdateState gates on HealthStatus.Healthy → Enabled, else Disabled - Wire builder.WithShowStatsCommand(databaseName) in WithMongoDbDevCommands - Unit tests: MongoDbStatsCommandTests (5 tests) - Collection definition: Infrastructure/MongoStatsIntegrationCollection - Integration tests: MongoShowStatsIntegrationTests (3 tests) - All 16 prior unit tests continue to pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MongoDbResourceBuilderExtensions.cs | 94 +++++++++ .../MongoStatsIntegrationCollection.cs | 19 ++ .../AppHost.Tests/MongoDbStatsCommandTests.cs | 186 ++++++++++++++++++ .../MongoShowStatsIntegrationTests.cs | 140 +++++++++++++ 4 files changed, 439 insertions(+) create mode 100644 tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs create mode 100644 tests/AppHost.Tests/MongoDbStatsCommandTests.cs create mode 100644 tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index d575abba..2beca5af 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -7,6 +7,8 @@ //Project Name : AppHost //======================================================= +using System.Text; + using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; @@ -29,6 +31,7 @@ public static IResourceBuilder WithMongoDbDevCommands( builder.WithClearDatabaseCommand(databaseName); builder.WithSeedDataCommand(databaseName); +builder.WithShowStatsCommand(databaseName); return builder; } @@ -261,4 +264,95 @@ private static void WithSeedDataCommand( : ResourceCommandState.Disabled }); } + +private static void WithShowStatsCommand( +this IResourceBuilder builder, +string databaseName) +{ +builder.WithCommand( +"show-myblog-stats", +"📊 Show MyBlog Stats", +executeCommand: async context => +{ +if (!await _clearMutex.WaitAsync(0)) +{ +context.Logger.LogWarning( +"Show MyBlog stats skipped on {ResourceName} — a database operation is already in progress.", +context.ResourceName); + +return CommandResults.Failure( +"A database operation is already in progress. Wait for the current run to finish, then try again."); +} + +try +{ +context.Logger.LogInformation( +"Show MyBlog stats invoked on {ResourceName} — querying '{Database}'.", +context.ResourceName, databaseName); + +var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); +if (connectionString is null) +{ +context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); +return CommandResults.Failure("Could not resolve MongoDB connection string. Is the MongoDB resource running?"); +} + +var client = new MongoClient(connectionString); +var database = client.GetDatabase(databaseName); + +var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); +var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); +var userCollections = collectionNames +.Where(static n => !n.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) +.ToList(); + +var sb = new StringBuilder(); +sb.AppendLine("| Collection | Document Count |"); +sb.AppendLine("| --- | --- |"); + +if (userCollections.Count == 0) +{ +sb.AppendLine("| *(no collections found)* | - |"); +} +else +{ +foreach (var name in userCollections) +{ +var col = database.GetCollection(name); +var count = await col.CountDocumentsAsync( +FilterDefinition.Empty, +cancellationToken: context.CancellationToken); +sb.AppendLine($"| {name} | {count} |"); +} +} + +var markdownTable = sb.ToString(); +context.Logger.LogInformation( +"Show MyBlog stats complete: {Count} collection(s) reported.", +userCollections.Count); + +return CommandResults.Success( +$"{userCollections.Count} collection(s) found in '{databaseName}'", +new CommandResultData +{ +Value = markdownTable, +Format = CommandResultFormat.Markdown, +DisplayImmediately = true +}); +} +finally +{ +_clearMutex.Release(); +} +}, +new CommandOptions +{ +Description = "Displays document counts per collection in the myblog database. Local development only.", +IconName = "ChartMultiple", +UpdateState = ctx => +ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy +? ResourceCommandState.Enabled +: ResourceCommandState.Disabled +}); +} } diff --git a/tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs b/tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs new file mode 100644 index 00000000..053c627d --- /dev/null +++ b/tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs @@ -0,0 +1,19 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoStatsIntegrationCollection.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +namespace AppHost.Tests.Infrastructure; + +/// +/// xUnit collection that shares one across all tests +/// in the "MongoStatsIntegration" collection (sequential execution, single Aspire host). +/// +[CollectionDefinition("MongoStatsIntegration")] +public sealed class MongoStatsIntegrationCollection : ICollectionFixture +{ +} diff --git a/tests/AppHost.Tests/MongoDbStatsCommandTests.cs b/tests/AppHost.Tests/MongoDbStatsCommandTests.cs new file mode 100644 index 00000000..38082bc9 --- /dev/null +++ b/tests/AppHost.Tests/MongoDbStatsCommandTests.cs @@ -0,0 +1,186 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoDbStatsCommandTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +using System.Collections.Immutable; + +using Aspire.Hosting; + +using FluentAssertions; + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AppHost.Tests; + +/// +/// Model-level tests for the local-only MongoDB show-stats operator command (issue #261). +/// These tests verify the Aspire resource annotation contract only — they do not start the +/// Aspire host, spin up containers, or touch a live database. +/// +public sealed class MongoDbStatsCommandTests +{ + private const string CommandName = "show-myblog-stats"; + + private static Task CreateBuilderAsync() => + DistributedApplicationTestingBuilder.CreateAsync( + args: [], + configureBuilder: static (options, _) => { options.DisableDashboard = true; }, + cancellationToken: TestContext.Current.CancellationToken); + + /// + /// Acceptance criterion #1: The mongodb resource exposes a "show-myblog-stats" operator action. + /// + [Fact] + public async Task MongoDb_Resource_Exposes_ShowMyBlogStats_Command_Annotation() + { + // Arrange + var builder = await CreateBuilderAsync(); + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + // Act + var annotation = mongoResource.Annotations + .OfType() + .SingleOrDefault(static a => a.Name == CommandName); + + // Assert + annotation.Should().NotBeNull( + "the mongodb resource must expose a 'show-myblog-stats' operator action per issue #261"); + } + + /// + /// Acceptance criterion: The show-stats command must NOT be highlighted — it is read-only + /// and should not carry a danger indicator. + /// + [Fact] + public async Task ShowMyBlogStats_Command_Is_Not_Highlighted() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + // Assert + annotation.IsHighlighted.Should().BeFalse( + "a read-only stats command must set IsHighlighted = false to avoid alarming the operator"); + } + + /// + /// Acceptance criterion: The show-stats command must NOT require a confirmation prompt. + /// It is a read-only query and needs no y/n dialog. + /// + [Fact] + public async Task ShowMyBlogStats_Command_Has_No_ConfirmationMessage() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + // Assert + annotation.ConfirmationMessage.Should().BeNullOrEmpty( + "the stats command is read-only and must not display a confirmation dialog"); + } + + /// + /// Acceptance criterion: The show-stats command is enabled only when MongoDB is healthy. + /// + [Fact] + public async Task ShowMyBlogStats_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Healthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Enabled, + "the show-myblog-stats command must be available when MongoDB is healthy"); + } + + /// + /// Acceptance criterion: The show-stats command must be disabled when MongoDB is not healthy + /// to prevent queries against an unstable or stopped container. + /// + [Fact] + public async Task ShowMyBlogStats_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Unhealthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Disabled, + "the show-myblog-stats command must be unavailable when MongoDB is unhealthy"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static ResourceCommandAnnotation GetAnnotation(IDistributedApplicationTestingBuilder builder) + { + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + return mongoResource.Annotations + .OfType() + .Single(static a => a.Name == CommandName); + } + + /// + /// Creates a with the given health status. + /// + /// has an internal init accessor and + /// is a private computed property — + /// both are inaccessible from external assemblies via normal C#. Reflection is required. + /// + /// + private static CustomResourceSnapshot BuildSnapshot(HealthStatus health) + { + var snapshot = new CustomResourceSnapshot + { + ResourceType = "MongoDB.Server", + Properties = [], + }; + + var reports = ImmutableArray.Create( + new HealthReportSnapshot("ready", health, null, null)); + + var type = typeof(CustomResourceSnapshot); + type + .GetProperty("HealthReports")! + .GetSetMethod(nonPublic: true)! + .Invoke(snapshot, [reports]); + + type.GetProperty("HealthStatus")! + .GetSetMethod(nonPublic: true)! + .Invoke(snapshot, [(HealthStatus?)health]); + + return snapshot; + } +} diff --git a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs new file mode 100644 index 00000000..8b952f24 --- /dev/null +++ b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs @@ -0,0 +1,140 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoShowStatsIntegrationTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +using AppHost.Tests.Infrastructure; + +using FluentAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using MongoDB.Bson; +using MongoDB.Driver; + +namespace AppHost.Tests; + +/// +/// Full integration tests for the "show-myblog-stats" operator command (issue #261). +/// +/// These tests require Docker because they boot the real Aspire host so that the handler's +/// closure-captured mongo.Resource.ConnectionStringExpression.GetValueAsync() can +/// resolve the live container's connection string. +/// +/// +[Collection("MongoStatsIntegration")] +public sealed class MongoShowStatsIntegrationTests(ClearCommandAppFixture fixture) +{ + private const string CommandName = "show-myblog-stats"; + + /// + /// When at least one collection with documents exists, the command returns success and + /// reports the collection count in its message. + /// + [Fact] + public async Task ShowMyBlogStats_Returns_Collection_Names_And_Counts_In_Markdown() + { + // Arrange — drop db, then insert documents into blogposts + await PrepareAsync(blogPostCount: 1); + + var annotation = GetAnnotation(); + var ctx = MakeContext(); + + // Act + var result = await annotation.ExecuteCommand(ctx); + + // Assert + result.Success.Should().BeTrue("the handler must succeed when MongoDB is reachable"); + result.Message.Should().Contain("collection(s)", + "the success message must report the number of collections found"); + } + + /// + /// When the database is completely empty (no user collections), the command must still + /// succeed — an empty database is not an error condition. + /// + [Fact] + public async Task ShowMyBlogStats_Empty_Database_Returns_No_Collections_Found() + { + // Arrange — drop the entire myblog database so no collection exists + await PrepareAsync(blogPostCount: 0); + + var annotation = GetAnnotation(); + var ctx = MakeContext(); + + // Act + var result = await annotation.ExecuteCommand(ctx); + + // Assert + result.Success.Should().BeTrue("an empty database must still return success — no collections is not an error"); + result.Message.Should().Contain("0 collection(s)", + "the message must indicate zero collections were found in the empty database"); + } + + /// + /// Two simultaneous stats attempts must not run together: exactly one proceeds and + /// the other fails fast with operator-visible feedback. + /// + [Fact] + public async Task ShowMyBlogStats_Concurrent_Invocations_Allow_Only_One_Run() + { + // Arrange + await PrepareAsync(blogPostCount: 0); + + var annotation = GetAnnotation(); + + // Act — fire two concurrent stats operations + var firstTask = annotation.ExecuteCommand(MakeContext()); + var secondTask = annotation.ExecuteCommand(MakeContext()); + var results = await Task.WhenAll(firstTask, secondTask); + + // Assert + results.Count(static r => r.Success).Should().Be(1, + "the semaphore should allow only one stats operation to run at a time"); + results.Count(static r => !r.Success).Should().Be(1, + "the overlapping stats attempt should fail fast instead of queueing"); + results.Single(static r => !r.Success).Message.Should().Contain("already in progress", + "the operator needs immediate feedback when another database operation is in flight"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private async Task PrepareAsync(int blogPostCount = 0) + { + var client = new MongoClient(fixture.MongoConnectionString); + await client.DropDatabaseAsync("myblog", TestContext.Current.CancellationToken); + if (blogPostCount > 0) + { + var db = client.GetDatabase("myblog"); + var col = db.GetCollection("blogposts"); + var docs = Enumerable.Range(0, blogPostCount) + .Select(i => new BsonDocument("n", i)) + .ToList(); + await col.InsertManyAsync(docs, cancellationToken: TestContext.Current.CancellationToken); + } + } + + private ResourceCommandAnnotation GetAnnotation() + { + var mongoResource = fixture.Builder.Resources.Single(static r => r.Name == "mongodb"); + + return mongoResource.Annotations + .OfType() + .Single(static a => a.Name == CommandName); + } + + private static ExecuteCommandContext MakeContext() => new() + { + ResourceName = "mongodb", + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + Logger = NullLogger.Instance, + CancellationToken = TestContext.Current.CancellationToken, + }; +} From 2d0289e1ad10af88a65254a892d680d70bbe61fd Mon Sep 17 00:00:00 2001 From: Boromir Date: Fri, 8 May 2026 10:09:03 -0700 Subject: [PATCH 2/2] fix: seed 50 docs in concurrent stats test to ensure mutex overlap window The empty-DB path completes in microseconds, so both concurrent invocations succeed before either releases the semaphore. Seeding 50 documents gives the first task real I/O work, ensuring the second task hits WaitAsync(0) while the semaphore is held. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs index 8b952f24..92cab2e6 100644 --- a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs @@ -83,7 +83,7 @@ public async Task ShowMyBlogStats_Empty_Database_Returns_No_Collections_Found() public async Task ShowMyBlogStats_Concurrent_Invocations_Allow_Only_One_Run() { // Arrange - await PrepareAsync(blogPostCount: 0); + await PrepareAsync(blogPostCount: 50); var annotation = GetAnnotation();