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, + }; +}