From 8571bf3b118a94fd1904e16c8ace22747a122c49 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 09:24:21 -0700 Subject: [PATCH 1/7] refactor(apphost): extract WithClearDatabaseCommand into MongoDbResourceBuilderExtensions (#262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extracts the inline `WithCommand` clear-data block from `AppHost.cs` into a new `MongoDbResourceBuilderExtensions` class. ## Changes - **New**: `src/AppHost/MongoDbResourceBuilderExtensions.cs` — contains `WithMongoDbDevCommands` public entry point and private `WithClearDatabaseCommand` - **Simplified**: `src/AppHost/AppHost.cs` — reduced from ~157 lines to ~30 lines; single `mongo.WithMongoDbDevCommands("myblog")` call ## Testing All 10 existing tests pass: - 5 unit tests in `MongoDbClearCommandTests` - 5 integration tests in `MongoClearDataIntegrationTests` Closes #259 Working as Sam (Backend/.NET) Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/AppHost/AppHost.cs | 129 +-------------- .../MongoDbResourceBuilderExtensions.cs | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 128 deletions(-) create mode 100644 src/AppHost/MongoDbResourceBuilderExtensions.cs diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs index fcc41c3f..bd542dbe 100644 --- a/src/AppHost/AppHost.cs +++ b/src/AppHost/AppHost.cs @@ -6,11 +6,6 @@ //Solution Name : MyBlog //Project Name : AppHost //======================================================= -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; - -using MongoDB.Bson; -using MongoDB.Driver; var builder = DistributedApplication.CreateBuilder(args); @@ -19,129 +14,7 @@ var mongoDb = mongo.AddDatabase("myblog"); var redis = builder.AddRedis("redis"); -// AC2 (#249): Semaphore prevents overlapping clear runs. A second concurrent invocation -// returns immediately with operator feedback instead of racing against the first. -var clearMutex = new SemaphoreSlim(1, 1); - -// Expose the destructive clear-data action only during local runs (IsRunMode = false when publishing). -if (builder.ExecutionContext.IsRunMode) -{ - mongo.WithCommand( - "clear-myblog-data", - "⚠️ Clear MyBlog Data", - executeCommand: async context => - { - // AC2: Non-blocking acquire — return immediately if another clear is already in flight. - if (!await clearMutex.WaitAsync(0)) - { - context.Logger.LogWarning( - "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", - context.ResourceName); - - return new ExecuteCommandResult - { - Success = false, - Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." - }; - } - - try - { - context.Logger.LogWarning( - "Clear MyBlog data invoked on {ResourceName} — enumerating collections in 'myblog'.", - context.ResourceName); - - var connectionString = await mongo.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); - if (connectionString is null) - { - context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); - return new ExecuteCommandResult - { - Success = false, - Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" - }; - } - - var client = new MongoClient(connectionString); - var database = client.GetDatabase("myblog"); - - var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); - var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); - - var results = new List<(string Name, long Deleted)>(); - var warnings = new List(); - - foreach (var name in collectionNames) - { - // Skip MongoDB internal system collections (e.g. system.views, system.users). - if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) - continue; - - try - { - // AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, - // and the loop continues so remaining collections are still processed. - var collection = database.GetCollection(name); - var deleteResult = await collection.DeleteManyAsync( - FilterDefinition.Empty, - context.CancellationToken); - - results.Add((name, deleteResult.DeletedCount)); - - context.Logger.LogInformation( - "Collection '{Collection}': {Count} document(s) deleted.", - name, deleteResult.DeletedCount); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - var warning = $"{name}: {ex.Message}"; - warnings.Add(warning); - context.Logger.LogWarning( - ex, - "Collection '{Collection}' could not be cleared — skipping and continuing.", - name); - } - } - - var totalDeleted = results.Sum(static r => r.Deleted); - var perCollection = results.Count == 0 - ? "no non-system collections found" - : string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); - - context.Logger.LogWarning( - "Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", - totalDeleted, results.Count, warnings.Count); - - var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; - if (warnings.Count > 0) - message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; - - return new ExecuteCommandResult - { - Success = true, - Message = message - }; - } - finally - { - clearMutex.Release(); - } - }, - new CommandOptions - { - Description = "Permanently deletes all data from the myblog database. Local development only.", - ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", - IsHighlighted = true, - IconName = "DatabaseWarning", - // AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT - // check dependent resources (Web, etc.). Clearing is valid while the app is live against - // local Mongo; the Web app running is not a reason to disable the command. - UpdateState = ctx => - ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled - }); -} +mongo.WithMongoDbDevCommands("myblog"); builder.AddProject("web") .WithReference(mongoDb) diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs new file mode 100644 index 00000000..bacb1a99 --- /dev/null +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -0,0 +1,154 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : MongoDbResourceBuilderExtensions.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : AppHost +//======================================================= + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Aspire.Hosting; + +internal static class MongoDbResourceBuilderExtensions +{ + // Shared semaphore — one per process; all commands share the same mutex. + private static readonly SemaphoreSlim _clearMutex = new(1, 1); + + public static IResourceBuilder WithMongoDbDevCommands( + this IResourceBuilder builder, + string databaseName) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + return builder; + + builder.WithClearDatabaseCommand(databaseName); + return builder; + } + + private static void WithClearDatabaseCommand( + this IResourceBuilder builder, + string databaseName) + { + builder.WithCommand( + "clear-myblog-data", + "⚠️ Clear MyBlog Data", + executeCommand: async context => + { + // AC2: Non-blocking acquire — return immediately if another clear is already in flight. + if (!await _clearMutex.WaitAsync(0)) + { + context.Logger.LogWarning( + "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", + context.ResourceName); + + return new ExecuteCommandResult + { + Success = false, + Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." + }; + } + + try + { + context.Logger.LogWarning( + "Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{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 new ExecuteCommandResult + { + Success = false, + Message = "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 results = new List<(string Name, long Deleted)>(); + var warnings = new List(); + + foreach (var name in collectionNames) + { + // Skip MongoDB internal system collections (e.g. system.views, system.users). + if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + // AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, + // and the loop continues so remaining collections are still processed. + var collection = database.GetCollection(name); + var deleteResult = await collection.DeleteManyAsync( + FilterDefinition.Empty, + context.CancellationToken); + + results.Add((name, deleteResult.DeletedCount)); + + context.Logger.LogInformation( + "Collection '{Collection}': {Count} document(s) deleted.", + name, deleteResult.DeletedCount); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var warning = $"{name}: {ex.Message}"; + warnings.Add(warning); + context.Logger.LogWarning( + ex, + "Collection '{Collection}' could not be cleared — skipping and continuing.", + name); + } + } + + var totalDeleted = results.Sum(static r => r.Deleted); + var perCollection = results.Count == 0 + ? "no non-system collections found" + : string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); + + context.Logger.LogWarning( + "Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", + totalDeleted, results.Count, warnings.Count); + + var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; + if (warnings.Count > 0) + message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; + + return new ExecuteCommandResult + { + Success = true, + Message = message + }; + } + finally + { + _clearMutex.Release(); + } + }, + new CommandOptions + { + Description = "Permanently deletes all data from the myblog database. Local development only.", + ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", + IsHighlighted = true, + IconName = "DatabaseWarning", + // AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT + // check dependent resources (Web, etc.). Clearing is valid while the app is live against + // local Mongo; the Web app running is not a reason to disable the command. + UpdateState = ctx => + ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled + }); + } +} From 976ec57f6ea08db9d5aa67c4a81f8a7fe3ed5e2c Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 09:31:31 -0700 Subject: [PATCH 2/7] fix(ci): use GH_PROJECT_TOKEN for Projects V2 access in squad-mark-released (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the `squad-mark-released` workflow which was failing with: > `GraphqlResponseError: Resource not accessible by integration` ## Root Cause `GITHUB_TOKEN` cannot access GitHub Projects V2 via GraphQL mutations. This is a known GitHub limitation — Projects V2 mutations require a PAT with `project` scope. ## Fix Swap `secrets.GITHUB_TOKEN` → `secrets.GH_PROJECT_TOKEN`, which is the PAT already used by `project-board-automation.yml` and `add-issues-to-project.yml` for Projects V2 access. ## Board Update The v1.4.0 board update was performed manually — 22 items moved from **Done → Released** directly via GraphQL. ## Related - Fixes the `squad-mark-released` auto-trigger failure for v1.4.0 - Ensures future releases auto-update the board correctly --------- Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-mark-released.yml | 2 +- .vscode/settings.json | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/squad-mark-released.yml b/.github/workflows/squad-mark-released.yml index 845da55d..997b4510 100644 --- a/.github/workflows/squad-mark-released.yml +++ b/.github/workflows/squad-mark-released.yml @@ -24,7 +24,7 @@ jobs: - name: Move Done → Released on project board uses: actions/github-script@v9 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GH_PROJECT_TOKEN }} script: | const PROJECT_ID = process.env.PROJECT_ID; const STATUS_FIELD_ID = process.env.STATUS_FIELD_ID; diff --git a/.vscode/settings.json b/.vscode/settings.json index 70d34f9c..e65f2809 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,9 @@ { "cSpell.words": [ "ASPNETCORE", + "cref", "EECOM", + "inheritdoc", "msbuild", "rendermode", "reskill", From 924edfc63a93c39537dc245d31fe39a8d0780c88 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 09:53:12 -0700 Subject: [PATCH 3/7] feat(AppHost): add WithSeedDataCommand for local dev seeding (#263) Closes #260 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MongoDbResourceBuilderExtensions.cs | 378 +++++++++++------- .../MongoSeedIntegrationCollection.cs | 19 + .../AppHost.Tests/MongoDbSeedCommandTests.cs | 201 ++++++++++ .../MongoSeedDataIntegrationTests.cs | 146 +++++++ 4 files changed, 610 insertions(+), 134 deletions(-) create mode 100644 tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs create mode 100644 tests/AppHost.Tests/MongoDbSeedCommandTests.cs create mode 100644 tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index bacb1a99..d575abba 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -17,138 +17,248 @@ namespace Aspire.Hosting; internal static class MongoDbResourceBuilderExtensions { - // Shared semaphore — one per process; all commands share the same mutex. - private static readonly SemaphoreSlim _clearMutex = new(1, 1); - - public static IResourceBuilder WithMongoDbDevCommands( - this IResourceBuilder builder, - string databaseName) - { - if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) - return builder; - - builder.WithClearDatabaseCommand(databaseName); - return builder; - } - - private static void WithClearDatabaseCommand( - this IResourceBuilder builder, - string databaseName) - { - builder.WithCommand( - "clear-myblog-data", - "⚠️ Clear MyBlog Data", - executeCommand: async context => - { - // AC2: Non-blocking acquire — return immediately if another clear is already in flight. - if (!await _clearMutex.WaitAsync(0)) - { - context.Logger.LogWarning( - "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", - context.ResourceName); - - return new ExecuteCommandResult - { - Success = false, - Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." - }; - } - - try - { - context.Logger.LogWarning( - "Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{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 new ExecuteCommandResult - { - Success = false, - Message = "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 results = new List<(string Name, long Deleted)>(); - var warnings = new List(); - - foreach (var name in collectionNames) - { - // Skip MongoDB internal system collections (e.g. system.views, system.users). - if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) - continue; - - try - { - // AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, - // and the loop continues so remaining collections are still processed. - var collection = database.GetCollection(name); - var deleteResult = await collection.DeleteManyAsync( - FilterDefinition.Empty, - context.CancellationToken); - - results.Add((name, deleteResult.DeletedCount)); - - context.Logger.LogInformation( - "Collection '{Collection}': {Count} document(s) deleted.", - name, deleteResult.DeletedCount); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - var warning = $"{name}: {ex.Message}"; - warnings.Add(warning); - context.Logger.LogWarning( - ex, - "Collection '{Collection}' could not be cleared — skipping and continuing.", - name); - } - } - - var totalDeleted = results.Sum(static r => r.Deleted); - var perCollection = results.Count == 0 - ? "no non-system collections found" - : string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); - - context.Logger.LogWarning( - "Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", - totalDeleted, results.Count, warnings.Count); - - var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; - if (warnings.Count > 0) - message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; - - return new ExecuteCommandResult - { - Success = true, - Message = message - }; - } - finally - { - _clearMutex.Release(); - } - }, - new CommandOptions - { - Description = "Permanently deletes all data from the myblog database. Local development only.", - ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", - IsHighlighted = true, - IconName = "DatabaseWarning", - // AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT - // check dependent resources (Web, etc.). Clearing is valid while the app is live against - // local Mongo; the Web app running is not a reason to disable the command. - UpdateState = ctx => - ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled - }); - } +// Shared semaphore — one per process; all commands share the same mutex. +private static readonly SemaphoreSlim _clearMutex = new(1, 1); + +public static IResourceBuilder WithMongoDbDevCommands( +this IResourceBuilder builder, +string databaseName) +{ +if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) +return builder; + +builder.WithClearDatabaseCommand(databaseName); +builder.WithSeedDataCommand(databaseName); +return builder; +} + +private static void WithClearDatabaseCommand( +this IResourceBuilder builder, +string databaseName) +{ +builder.WithCommand( +"clear-myblog-data", +"⚠️ Clear MyBlog Data", +executeCommand: async context => +{ +// AC2: Non-blocking acquire — return immediately if another clear is already in flight. +if (!await _clearMutex.WaitAsync(0)) +{ +context.Logger.LogWarning( +"Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", +context.ResourceName); + +return new ExecuteCommandResult +{ +Success = false, +Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." +}; +} + +try +{ +context.Logger.LogWarning( +"Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{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 new ExecuteCommandResult +{ +Success = false, +Message = "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 results = new List<(string Name, long Deleted)>(); +var warnings = new List(); + +foreach (var name in collectionNames) +{ +// Skip MongoDB internal system collections (e.g. system.views, system.users). +if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) +continue; + +try +{ +// AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, +// and the loop continues so remaining collections are still processed. +var collection = database.GetCollection(name); +var deleteResult = await collection.DeleteManyAsync( +FilterDefinition.Empty, +context.CancellationToken); + +results.Add((name, deleteResult.DeletedCount)); + +context.Logger.LogInformation( +"Collection '{Collection}': {Count} document(s) deleted.", +name, deleteResult.DeletedCount); +} +catch (Exception ex) when (ex is not OperationCanceledException) +{ +var warning = $"{name}: {ex.Message}"; +warnings.Add(warning); +context.Logger.LogWarning( +ex, +"Collection '{Collection}' could not be cleared — skipping and continuing.", +name); +} +} + +var totalDeleted = results.Sum(static r => r.Deleted); +var perCollection = results.Count == 0 +? "no non-system collections found" +: string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); + +context.Logger.LogWarning( +"Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", +totalDeleted, results.Count, warnings.Count); + +var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; +if (warnings.Count > 0) +message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; + +return new ExecuteCommandResult +{ +Success = true, +Message = message +}; +} +finally +{ +_clearMutex.Release(); +} +}, +new CommandOptions +{ +Description = "Permanently deletes all data from the myblog database. Local development only.", +ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", +IsHighlighted = true, +IconName = "DatabaseWarning", +// AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT +// check dependent resources (Web, etc.). Clearing is valid while the app is live against +// local Mongo; the Web app running is not a reason to disable the command. +UpdateState = ctx => +ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy +? ResourceCommandState.Enabled +: ResourceCommandState.Disabled +}); +} + +private static void WithSeedDataCommand( +this IResourceBuilder builder, +string databaseName) +{ +builder.WithCommand( +"seed-myblog-data", +"🌱 Seed MyBlog Data", +executeCommand: async context => +{ +if (!await _clearMutex.WaitAsync(0)) +{ +context.Logger.LogWarning( +"Seed MyBlog data skipped on {ResourceName} — a database operation is already in progress.", +context.ResourceName); + +return new ExecuteCommandResult +{ +Success = false, +Message = "A database operation is already in progress. Wait for the current run to finish, then try again." +}; +} + +try +{ +context.Logger.LogInformation( +"Seed MyBlog data invoked on {ResourceName} — inserting seed data into '{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 new ExecuteCommandResult +{ +Success = false, +Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" +}; +} + +var client = new MongoClient(connectionString); +var database = client.GetDatabase(databaseName); +var collection = database.GetCollection("blogposts"); + +var now = DateTime.UtcNow; +var seedDocuments = new BsonDocument[] +{ +new() +{ +["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), +["Title"] = "Welcome to MyBlog", +["Content"] = "This is the first post on MyBlog. Welcome!", +["Author"] = "Matthew Paulosky", +["CreatedAt"] = now, +["UpdatedAt"] = now, +["IsPublished"] = true, +["Version"] = 1, +}, +new() +{ +["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), +["Title"] = "Getting Started with .NET Aspire", +["Content"] = "Learn how to build cloud-native apps with .NET Aspire.", +["Author"] = "Matthew Paulosky", +["CreatedAt"] = now, +["UpdatedAt"] = now, +["IsPublished"] = true, +["Version"] = 1, +}, +new() +{ +["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), +["Title"] = "Draft: MongoDB Performance Tips", +["Content"] = "Work in progress — tips for optimising MongoDB queries.", +["Author"] = "Matthew Paulosky", +["CreatedAt"] = now, +["UpdatedAt"] = now, +["IsPublished"] = false, +["Version"] = 1, +}, +}; + +await collection.InsertManyAsync(seedDocuments, cancellationToken: context.CancellationToken); + +context.Logger.LogInformation( +"Seed MyBlog data complete: {Count} blog post(s) inserted.", +seedDocuments.Length); + +return new ExecuteCommandResult +{ +Success = true, +Message = $"blogposts: {seedDocuments.Length} inserted (2 published, 1 draft)" +}; +} +finally +{ +_clearMutex.Release(); +} +}, +new CommandOptions +{ +Description = "Inserts seed blog posts into the myblog database. Local development only.", +IconName = "DatabaseArrowUp", +UpdateState = ctx => +ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy +? ResourceCommandState.Enabled +: ResourceCommandState.Disabled +}); +} } diff --git a/tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs b/tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs new file mode 100644 index 00000000..7c3e548c --- /dev/null +++ b/tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs @@ -0,0 +1,19 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoSeedIntegrationCollection.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 "MongoSeedIntegration" collection (sequential execution, single Aspire host). +/// +[CollectionDefinition("MongoSeedIntegration")] +public sealed class MongoSeedIntegrationCollection : ICollectionFixture +{ +} diff --git a/tests/AppHost.Tests/MongoDbSeedCommandTests.cs b/tests/AppHost.Tests/MongoDbSeedCommandTests.cs new file mode 100644 index 00000000..335f0ff7 --- /dev/null +++ b/tests/AppHost.Tests/MongoDbSeedCommandTests.cs @@ -0,0 +1,201 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoDbSeedCommandTests.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 seed-data operator command (issue #260). +/// 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 MongoDbSeedCommandTests +{ + private const string CommandName = "seed-myblog-data"; + + 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 "seed-myblog-data" operator action. + /// + [Fact] + public async Task MongoDb_Resource_Exposes_SeedMyBlogData_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 'seed-myblog-data' operator action per issue #260"); + } + + /// + /// Acceptance criterion: The seed command must NOT be highlighted — it is additive, + /// not destructive, so it should not carry a danger indicator. + /// + [Fact] + public async Task SeedMyBlogData_Command_Is_Not_Highlighted() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + // Assert + annotation.IsHighlighted.Should().BeFalse( + "an additive seed command must set IsHighlighted = false to avoid alarming the operator"); + } + + /// + /// Acceptance criterion: The seed command must NOT require a confirmation prompt. + /// Seeding is non-destructive and reversible, so no y/n dialog is needed. + /// + [Fact] + public async Task SeedMyBlogData_Command_Has_No_ConfirmationMessage() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + // Assert + annotation.ConfirmationMessage.Should().BeNullOrEmpty( + "the seed command is additive and must not display a confirmation dialog"); + } + + /// + /// Acceptance criterion: The seed command's icon must be "DatabaseArrowUp". + /// + [Fact] + public async Task SeedMyBlogData_Command_Has_DatabaseArrowUp_Icon() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + // Assert + annotation.IconName.Should().Be("DatabaseArrowUp", + "the seed command must use the DatabaseArrowUp icon per issue #260"); + } + + /// + /// Acceptance criterion: The seed command is enabled only when MongoDB is healthy. + /// + [Fact] + public async Task SeedMyBlogData_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(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 seed-myblog-data command must be available when MongoDB is healthy"); + } + + /// + /// Acceptance criterion: The seed command must be disabled when MongoDB is not healthy + /// to prevent inserts against an unstable or stopped container. + /// + [Fact] + public async Task SeedMyBlogData_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(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 seed-myblog-data command must be unavailable when MongoDB is unhealthy"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static ResourceCommandAnnotation GetSeedMyBlogDataAnnotation(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/MongoSeedDataIntegrationTests.cs b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs new file mode 100644 index 00000000..f9add810 --- /dev/null +++ b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs @@ -0,0 +1,146 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoSeedDataIntegrationTests.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 "seed-myblog-data" operator command (issue #260). +/// +/// 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("MongoSeedIntegration")] +public sealed class MongoSeedDataIntegrationTests(ClearCommandAppFixture fixture) +{ + private const string CommandName = "seed-myblog-data"; + + /// + /// After the command runs, the blogposts collection contains at least 3 documents, + /// including at least one unpublished draft. + /// + [Fact] + public async Task SeedMyBlogData_Inserts_Expected_Documents_Into_BlogPosts_Collection() + { + // Arrange — drop and recreate an empty blogposts collection + 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); + + 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"); + + var count = await db.GetCollection("blogposts") + .CountDocumentsAsync(FilterDefinition.Empty, cancellationToken: TestContext.Current.CancellationToken); + + count.Should().BeGreaterThanOrEqualTo(3, "at least 3 seed documents must be inserted"); + + var draftCount = await db.GetCollection("blogposts") + .CountDocumentsAsync( + Builders.Filter.Eq("IsPublished", false), + cancellationToken: TestContext.Current.CancellationToken); + + draftCount.Should().BeGreaterThanOrEqualTo(1, "at least 1 document must be unpublished (draft)"); + } + + /// + /// Two simultaneous seed attempts must not run together: exactly one proceeds and + /// the other fails fast with operator-visible feedback. + /// + [Fact] + public async Task SeedMyBlogData_Concurrent_Invocations_Allow_Only_One_Run() + { + // Arrange + 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); + + var annotation = GetAnnotation(); + + // Act — fire two concurrent seed 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 seed operation to run at a time"); + results.Count(static r => !r.Success).Should().Be(1, + "the overlapping seed 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"); + } + + /// + /// When the database is completely empty (no collections at all), seeding must still + /// create the blogposts collection and insert documents successfully. + /// + [Fact] + 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); + await client.DropDatabaseAsync("myblog", TestContext.Current.CancellationToken); + + var annotation = GetAnnotation(); + var ctx = MakeContext(); + + // Act + var result = await annotation.ExecuteCommand(ctx); + + // Assert + result.Success.Should().BeTrue("seeding an empty database must succeed"); + + var db = client.GetDatabase("myblog"); + var count = await db.GetCollection("blogposts") + .CountDocumentsAsync(FilterDefinition.Empty, cancellationToken: TestContext.Current.CancellationToken); + + count.Should().BeGreaterThanOrEqualTo(1, "blogposts collection must have documents after seed"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + 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 002b5bb53b762373e1d13a16ae8420e9b369c41d Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 10:16:11 -0700 Subject: [PATCH 4/7] feat(AppHost): add WithShowStatsCommand for local dev stats (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #261 Adds `WithShowStatsCommand` — the third and final Aspire dashboard command in `MongoDbResourceBuilderExtensions`: - Command name `show-myblog-stats`, icon `ChartMultiple`, non-highlighted - Markdown table of collection → document count via `_clearMutex` non-blocking guard - Empty DB returns `*(no collections found)*` row; `system.*` collections filtered - 5 unit tests + 3 integration tests (concurrent-invocation fix: seed 50 docs) 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..92cab2e6 --- /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: 50); + + 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 d1d7e7933422799aa6732e1d84b5efc1005b67da Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 11:22:16 -0700 Subject: [PATCH 5/7] refactor: rename _clearMutex to _dbMutex in MongoDbResourceBuilderExtensions (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Renames the shared semaphore `_clearMutex` → `_dbMutex` in `MongoDbResourceBuilderExtensions`. The semaphore guards all three MongoDB dev commands (Clear, Seed, Stats), not just clear. The old name was misleading. ## Changes - `src/AppHost/MongoDbResourceBuilderExtensions.cs`: rename field declaration and all 6 usage sites (3× WaitAsync + 3× Release) plus updated comment ## Testing - Build: ✅ 0 errors - Architecture.Tests: ✅ 15/15 - Domain.Tests: ✅ 42/42 - Integration.Tests: ✅ 12/12 - No behavior change — rename only Closes #266 Working as Sam (Backend / .NET) Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/AppHost/MongoDbResourceBuilderExtensions.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index 2beca5af..a8b08d2e 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -19,8 +19,8 @@ namespace Aspire.Hosting; internal static class MongoDbResourceBuilderExtensions { -// Shared semaphore — one per process; all commands share the same mutex. -private static readonly SemaphoreSlim _clearMutex = new(1, 1); +// Shared semaphore — guards all three dev commands (Clear, Seed, Stats) so only one runs at a time. +private static readonly SemaphoreSlim _dbMutex = new(1, 1); public static IResourceBuilder WithMongoDbDevCommands( this IResourceBuilder builder, @@ -45,7 +45,7 @@ private static void WithClearDatabaseCommand( executeCommand: async context => { // AC2: Non-blocking acquire — return immediately if another clear is already in flight. -if (!await _clearMutex.WaitAsync(0)) +if (!await _dbMutex.WaitAsync(0)) { context.Logger.LogWarning( "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", @@ -137,7 +137,7 @@ private static void WithClearDatabaseCommand( } finally { -_clearMutex.Release(); +_dbMutex.Release(); } }, new CommandOptions @@ -165,7 +165,7 @@ private static void WithSeedDataCommand( "🌱 Seed MyBlog Data", executeCommand: async context => { -if (!await _clearMutex.WaitAsync(0)) +if (!await _dbMutex.WaitAsync(0)) { context.Logger.LogWarning( "Seed MyBlog data skipped on {ResourceName} — a database operation is already in progress.", @@ -251,7 +251,7 @@ private static void WithSeedDataCommand( } finally { -_clearMutex.Release(); +_dbMutex.Release(); } }, new CommandOptions @@ -274,7 +274,7 @@ private static void WithShowStatsCommand( "📊 Show MyBlog Stats", executeCommand: async context => { -if (!await _clearMutex.WaitAsync(0)) +if (!await _dbMutex.WaitAsync(0)) { context.Logger.LogWarning( "Show MyBlog stats skipped on {ResourceName} — a database operation is already in progress.", @@ -342,7 +342,7 @@ private static void WithShowStatsCommand( } finally { -_clearMutex.Release(); +_dbMutex.Release(); } }, new CommandOptions From 3c309d220d42981704d12e9168a5dfc04cbd9b6a Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 11:30:04 -0700 Subject: [PATCH 6/7] =?UTF-8?q?fix(ci):=20Blog=20=E2=86=92=20README=20Sync?= =?UTF-8?q?=20=E2=80=94=20push=20to=20dev=20instead=20of=20main=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `blog-readme-sync.yml` workflow was pushing `README.md` updates directly to `main`, which is blocked by branch protection rules. ## Fix (Option C) Changed the push target from `git push` (implicit HEAD → main) to `git push origin HEAD:dev`. - The workflow still **triggers** on `push: branches: [main]` (reads `docs/blog/index.md` from main) - The **README update** is now pushed to `dev`, flowing through the normal dev→main release cycle - No new secrets or PAT bypass permissions required - `permissions: contents: write` was already present ## Root Cause ``` remote: GH013: Repository rule violations found for refs/heads/main. remote: - Changes must be made through a pull request. remote: - Required status check "Build Solution" is expected. ``` Closes #269 Working as Boromir (DevOps) Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/blog-readme-sync.yml | 2 +- .squad/agents/boromir/history.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/blog-readme-sync.yml b/.github/workflows/blog-readme-sync.yml index 68ca82ab..75b398b7 100644 --- a/.github/workflows/blog-readme-sync.yml +++ b/.github/workflows/blog-readme-sync.yml @@ -78,5 +78,5 @@ jobs: git diff --quiet README.md || ( git add README.md && git commit -m "docs: sync Dev Blog section from docs/blog/index.md [skip ci]" && - git push + git push origin HEAD:dev ) diff --git a/.squad/agents/boromir/history.md b/.squad/agents/boromir/history.md index 47cd46ad..8abe7659 100644 --- a/.squad/agents/boromir/history.md +++ b/.squad/agents/boromir/history.md @@ -48,6 +48,18 @@ ## Learnings +### 2026-05-XX — Issue #269: Blog → README Sync workflow branch protection fix + +**Problem:** `blog-readme-sync.yml` pushed directly to `main` after updating `README.md`, which is blocked by branch protection rules (direct pushes forbidden, "Build Solution" check required). + +**Fix (Option C):** Changed `git push` to `git push origin HEAD:dev` in the "Commit updated README" step. The workflow still triggers on `push: branches: [main]` (reading `docs/blog/index.md` from main), but the README update is pushed to `dev` — the normal development branch — and flows through the standard dev→main release cycle. + +**Key insight:** The `permissions: contents: write` block was already present. No new secrets or PAT bypass needed. One-line change. + +**Decision:** Captured in `.squad/decisions/inbox/boromir-269-readme-sync-target.md`. + +--- + ### 2026-05-08 — Issue #249: AppHost Mongo Clear Hardening **What was done:** From c272febeac9d863b19f6ed9ab15fbeedbead785d Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 11:39:42 -0700 Subject: [PATCH 7/7] fix(ci): add pre-flight token validation and fix permissions block in squad-mark-released (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Working as Boromir (DevOps) Closes #268 ## Root Cause The workflow was failing with `Resource not accessible by integration` because: 1. `permissions: repository-projects: write` only controls `GITHUB_TOKEN` — it has **no effect** on a custom PAT passed via `github-token:` 2. When `GH_PROJECT_TOKEN` secret is not set, `actions/github-script` receives an empty string and falls back to using `GITHUB_TOKEN`, which **cannot** access GitHub Projects V2 GraphQL regardless of the permissions block ## Changes - **Fix permissions block**: `repository-projects: write` → `contents: read` (correct for workflows that rely exclusively on a custom PAT) - **Add pre-flight validation step**: Checks `GH_PROJECT_TOKEN` is set; fails early with an actionable error message if missing (includes setup instructions and required scope) - **Downgrade `actions/github-script@v9` → `@v7`** (stable LTS version) - **Add top-of-file comment** documenting that a classic PAT with `project` OAuth scope is required ## Setup Required To make this workflow functional, add `GH_PROJECT_TOKEN` as a repository secret: 1. Create a classic PAT at https://github.com/settings/tokens with `project` scope 2. Add it: Settings → Secrets and variables → Actions → New repository secret → `GH_PROJECT_TOKEN` Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-mark-released.yml | 20 ++++++++++++++++++-- .squad/agents/boromir/history.md | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/squad-mark-released.yml b/.github/workflows/squad-mark-released.yml index 997b4510..8588c9f3 100644 --- a/.github/workflows/squad-mark-released.yml +++ b/.github/workflows/squad-mark-released.yml @@ -5,8 +5,12 @@ on: types: [published, released] workflow_dispatch: {} +# NOTE: The default GITHUB_TOKEN cannot access GitHub Projects V2 via GraphQL. +# This workflow requires a repository secret named GH_PROJECT_TOKEN set to a +# classic Personal Access Token (PAT) with the `project` OAuth scope. +# See: https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects permissions: - repository-projects: write + contents: read env: PROJECT_ID: PVT_kwHOA5k0b84BVFTy @@ -21,8 +25,20 @@ jobs: runs-on: ubuntu-latest steps: + - name: Validate GH_PROJECT_TOKEN secret is configured + env: + TOKEN_CHECK: ${{ secrets.GH_PROJECT_TOKEN }} + run: | + if [ -z "$TOKEN_CHECK" ]; then + echo "::error::GH_PROJECT_TOKEN secret is not set." + echo "::error::Add a classic PAT with the 'project' OAuth scope as a repository secret named GH_PROJECT_TOKEN." + echo "::error::See: Settings → Secrets and variables → Actions → New repository secret" + exit 1 + fi + echo "✅ GH_PROJECT_TOKEN secret is present." + - name: Move Done → Released on project board - uses: actions/github-script@v9 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GH_PROJECT_TOKEN }} script: | diff --git a/.squad/agents/boromir/history.md b/.squad/agents/boromir/history.md index 8abe7659..d9fbf06a 100644 --- a/.squad/agents/boromir/history.md +++ b/.squad/agents/boromir/history.md @@ -57,6 +57,26 @@ **Key insight:** The `permissions: contents: write` block was already present. No new secrets or PAT bypass needed. One-line change. **Decision:** Captured in `.squad/decisions/inbox/boromir-269-readme-sync-target.md`. +### 2026-05-08 — Issue #268: Fix squad-mark-released GraphQL Permission Error + +**Root cause:** +The `permissions: repository-projects: write` block in the workflow was incorrect — it applies only to `GITHUB_TOKEN`, not to a custom PAT. The workflow uses `${{ secrets.GH_PROJECT_TOKEN }}`, but: + +1. If the secret is not set, `actions/github-script` receives an empty string and falls back to `GITHUB_TOKEN` +2. `GITHUB_TOKEN` cannot access GitHub Projects V2 GraphQL API, producing `Resource not accessible by integration` +3. Even if set, the PAT needs the `project` OAuth scope (classic PAT) for Projects V2 mutations + +**What was fixed:** + +1. Changed `permissions: repository-projects: write` → `permissions: contents: read` (correct for a workflow that only uses a custom PAT — no GITHUB_TOKEN escalation needed) +2. Added a pre-flight validation step that explicitly checks `GH_PROJECT_TOKEN` is set, failing early with an actionable error message including setup instructions +3. Downgraded `actions/github-script@v9` → `@v7` (stable LTS version) +4. Added a top-of-file comment documenting the required PAT scope (`project`) + +**Key lesson:** For GitHub Projects V2 GraphQL, `GITHUB_TOKEN` is never sufficient regardless of `permissions` block settings. A classic PAT with `project` OAuth scope (or fine-grained PAT with Projects read/write) is required. Always add a pre-flight secret validation step so failures are immediately actionable. + +**Files changed:** `.github/workflows/squad-mark-released.yml` +**Related decisions inbox:** `boromir-268-project-token.md` ---