diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push index 138b97a0..4bb27f8a 100755 --- a/.github/hooks/pre-push +++ b/.github/hooks/pre-push @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Pre-push gate: mirrors CI checks to catch failures before they reach GitHub -# Runs: branch protection → untracked-file check → Release build → unit/architecture tests → integration tests +# Runs: branch protection → untracked-file check → dotnet format → Release build → unit/architecture tests → integration tests # NOTE: git provides refspecs on stdin; interactive prompts must use /dev/tty. set -uo pipefail @@ -49,7 +49,34 @@ if [[ -n "$UNTRACKED_SRC" ]]; then fi fi -# ── Gate 2: Release build (mirrors CI exactly) ───────────────────────────── +# ── Gate 2: dotnet format check ──────────────────────────────────────────── +echo -e "\n${CYAN}🎨 Checking code formatting (dotnet format --verify-no-changes)...${RESET}" +dotnet format MyBlog.slnx --verify-no-changes 2>&1 +FORMAT_EXIT=$? + +if [[ $FORMAT_EXIT -ne 0 ]]; then + echo -e "${RED}❌ Formatting issues detected — one or more files require formatting changes.${RESET}" + echo -e "${YELLOW} Fix: dotnet format MyBlog.slnx${RESET}" + echo -e "${YELLOW} Then: git add -u && git commit (or --amend), then re-push.${RESET}" + echo "" + printf "Auto-fix formatting now? Modified files must be staged and committed before re-pushing. [y/N] " >/dev/tty + read -r FORMAT_FIX ``` -3. **Release build passes locally** — Gate 2 runs Release (not Debug) +3. **Code is formatted** — Gate 2 runs `dotnet format --verify-no-changes` + + ```bash + dotnet format MyBlog.slnx --verify-no-changes + ``` + + If formatting issues are found, fix with: + + ```bash + dotnet format MyBlog.slnx + git add -u + git commit # or --amend + ``` + +4. **Release build passes locally** — Gate 3 runs Release (not Debug) ```bash dotnet build IssueTrackerApp.slnx --configuration Release @@ -49,7 +63,7 @@ Before running `git push`, verify: If build fails, run `.github/prompts/build-repair.prompt.md` to fix. -4. **Unit tests pass** — Gate 3 runs 6 test projects +5. **Unit tests pass** — Gate 4 runs 6 test projects ```bash dotnet test tests/Architecture.Tests/Architecture.Tests.csproj --configuration Release --no-build @@ -60,13 +74,13 @@ Before running `git push`, verify: dotnet test tests/Persistence.AzureStorage.Tests/Persistence.AzureStorage.Tests.csproj --configuration Release --no-build ``` -5. **Docker is running** — Gate 4 requires Docker for integration tests +6. **Docker is running** — Gate 5 requires Docker for integration tests ```bash docker info &>/dev/null && echo "Docker OK" || echo "Docker NOT running" ``` -## The 5 Gates (What the Hook Runs) +## The 6 Gates (What the Hook Runs) When you execute `git push`, the hook runs automatically: @@ -74,11 +88,12 @@ When you execute `git push`, the hook runs automatically: | ----- | ---------------------- | ------------------------------------------------------------------------ | | **0** | Branch protection | Current branch is `main` or `dev` | | **1** | Untracked source files | `.razor`/`.cs` files not staged (prompts y/N) | -| **2** | Release build | `dotnet build --configuration Release` fails (3 attempts) | -| **3** | Unit/Arch/bUnit tests | Any of 6 test projects fail (3 attempts) | -| **4** | Integration tests | Any of 4 integration test projects fail; Docker not running (3 attempts) | +| **2** | dotnet format | Any file requires formatting changes (prompts auto-fix y/N) | +| **3** | Release build | `dotnet build --configuration Release` fails (3 attempts) | +| **4** | Unit/Arch/bUnit tests | Any of 6 test projects fail (3 attempts) | +| **5** | Integration tests | Any of 4 integration test projects fail; Docker not running (3 attempts) | -### Gate 3 — Test Projects (Unit) +### Gate 4 — Test Projects (Unit) ```text tests/Architecture.Tests/Architecture.Tests.csproj @@ -89,7 +104,7 @@ tests/Web.Tests/Web.Tests.csproj tests/Persistence.AzureStorage.Tests/Persistence.AzureStorage.Tests.csproj ``` -### Gate 4 — Integration Test Projects (Docker Required) +### Gate 5 — Integration Test Projects (Docker Required) ```text tests/Persistence.MongoDb.Tests.Integration/Persistence.MongoDb.Tests.Integration.csproj @@ -102,7 +117,7 @@ These use Testcontainers (mongo:7.0, Azurite) and Aspire DCP. Docker daemon MUST ## Retry Behavior -The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: +The hook allows **3 attempts** for Gates 3, 4, and 5. Between attempts: - The hook pauses and prompts "Fix the errors and press Enter to retry, or Ctrl+C to abort" - Fix the failing code, then press Enter @@ -110,7 +125,15 @@ The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: ## Troubleshooting -### Build Failure (Gate 2) +### Formatting Failure (Gate 2) + +| Symptom | Fix | +| ------------------------ | ----------------------------------------------------------------- | +| Files differ from format | Run `dotnet format MyBlog.slnx`, then `git add -u && git commit` | +| Analyzer rule violation | Run `dotnet format MyBlog.slnx --diagnostics ` to debug | +| dotnet format not found | Install .NET SDK matching `global.json`; format ships with SDK | + +### Build Failure (Gate 3) | Symptom | Fix | | ------------------------ | ----------------------------------------------------- | @@ -120,7 +143,7 @@ The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: **Escalation:** Run `.github/prompts/build-repair.prompt.md` for automated fix. -### Test Failure (Gate 3) +### Test Failure (Gate 4) | Symptom | Fix | | ------------------------- | ------------------------------------------------------------------------------------------------------------------ | @@ -128,7 +151,7 @@ The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: | bUnit test failure | Verify Blazor component rendering; check `Render()` not `RenderComponent()` (bUnit 2.x) | | DateTime equality failure | Assert individual fields, not whole-record equality (UtcNow varies between calls) | -### Integration Test Failure (Gate 4) +### Integration Test Failure (Gate 5) | Symptom | Fix | | ------------------------- | --------------------------------------------------------------- | @@ -148,6 +171,7 @@ chmod +x .git/hooks/pre-push ## Anti-Patterns - ❌ **Bypassing the hook** with `git push --no-verify` — CI will catch it, wasting time +- ❌ **Committing unformatted code** — Gate 2 blocks the push; run `dotnet format MyBlog.slnx` first - ❌ **Running Debug build only** — CI uses Release; Debug hides missing files - ❌ **Pushing without Docker** — Gate 4 will block; start Docker first - ❌ **Ignoring untracked files** — They're invisible to CI and will cause failures diff --git a/.squad/skills/pre-push-test-gate/SKILL.md b/.squad/skills/pre-push-test-gate/SKILL.md index c7dd12cf..87618de9 100644 --- a/.squad/skills/pre-push-test-gate/SKILL.md +++ b/.squad/skills/pre-push-test-gate/SKILL.md @@ -66,9 +66,10 @@ chmod +x .git/hooks/pre-push - Gate 0: Block direct push to `main` - Gate 1: Warn on untracked `.razor`/`.cs` files -- Gate 2: Release build (0 warnings, 0 errors) -- Gate 3: Unit + bUnit + Architecture tests (6 projects, no Docker) -- Gate 4: Integration + Playwright E2E — **AppHost.Tests included** (Docker required) +- Gate 2: `dotnet format --verify-no-changes` (formatting check; offers auto-fix) +- Gate 3: Release build (0 warnings, 0 errors) +- Gate 4: Unit + bUnit + Architecture tests (6 projects, no Docker) +- Gate 5: Integration + Playwright E2E — **AppHost.Tests included** (Docker required) **PowerShell (Windows):** diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index d1293273..9b29994d 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -81,12 +81,13 @@ echo "" echo "Pre-commit hook gates on every 'git commit':" echo " • Runs markdownlint on staged .md files (degrades gracefully if not installed)" echo "" -echo "Pre-push hook enforces 5 gates on every 'git push':" +echo "Pre-push hook enforces 6 gates on every 'git push':" echo " 0. Enforces branch naming — squad/{issue}-{slug} runs all gates;" echo " sprint/{N}-{slug} passes Gate 0 and exits (skips feature gates)" echo " 1. Warns about untracked .razor/.cs source files" -echo " 2. Release build (dotnet build MyBlog.slnx --configuration Release)" -echo " 3. Unit/arch tests (tests/Architecture.Tests, tests/Unit.Tests)" -echo " 4. Integration tests (tests/Integration.Tests — Docker required)" +echo " 2. dotnet format --verify-no-changes (formatting check; offers auto-fix)" +echo " 3. Release build (dotnet build MyBlog.slnx --configuration Release)" +echo " 4. Unit/arch tests (tests/Architecture.Tests, tests/Unit.Tests)" +echo " 5. Integration tests (tests/Integration.Tests — Docker required)" echo "" echo "To skip in an emergency: git commit --no-verify / git push --no-verify" diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index 2adf5d0e..3f64b163 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -19,199 +19,199 @@ namespace Aspire.Hosting; internal static class MongoDbResourceBuilderExtensions { -// 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); - -/// -/// Test-only hook used by AppHost.Tests to hold the seed command inside the shared mutex -/// so overlapping invocations can be asserted deterministically. -/// -internal static Func? SeedCommandAfterMutexAcquiredAsync { get; set; } - -public static IResourceBuilder WithMongoDbDevCommands( -this IResourceBuilder builder, -string databaseName) -{ -if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) -return builder; - -builder.WithClearDatabaseCommand(databaseName); -builder.WithSeedDataCommand(databaseName); -builder.WithShowStatsCommand(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 _dbMutex.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 -{ -_dbMutex.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 _dbMutex.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 -{ - var afterMutexAcquired = SeedCommandAfterMutexAcquiredAsync; - if (afterMutexAcquired is not null) - await afterMutexAcquired(context.CancellationToken); - -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[] -{ + // 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); + + /// + /// Test-only hook used by AppHost.Tests to hold the seed command inside the shared mutex + /// so overlapping invocations can be asserted deterministically. + /// + internal static Func? SeedCommandAfterMutexAcquiredAsync { get; set; } + + public static IResourceBuilder WithMongoDbDevCommands( + this IResourceBuilder builder, + string databaseName) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + return builder; + + builder.WithClearDatabaseCommand(databaseName); + builder.WithSeedDataCommand(databaseName); + builder.WithShowStatsCommand(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 _dbMutex.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 + { + _dbMutex.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 _dbMutex.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 + { + var afterMutexAcquired = SeedCommandAfterMutexAcquiredAsync; + if (afterMutexAcquired is not null) + await afterMutexAcquired(context.CancellationToken); + + 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), @@ -245,124 +245,124 @@ private static void WithSeedDataCommand( ["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 -{ -_dbMutex.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 -}); -} - -private static void WithShowStatsCommand( -this IResourceBuilder builder, -string databaseName) -{ -builder.WithCommand( -"show-myblog-stats", -"📊 Show MyBlog Stats", -executeCommand: async context => -{ -if (!await _dbMutex.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 -{ -_dbMutex.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 -}); -} + }; + + 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 + { + _dbMutex.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 + }); + } + + private static void WithShowStatsCommand( + this IResourceBuilder builder, + string databaseName) + { + builder.WithCommand( + "show-myblog-stats", + "📊 Show MyBlog Stats", + executeCommand: async context => + { + if (!await _dbMutex.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 + { + _dbMutex.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/src/Domain/Entities/BlogPost.cs b/src/Domain/Entities/BlogPost.cs index 7e29098c..36964f18 100644 --- a/src/Domain/Entities/BlogPost.cs +++ b/src/Domain/Entities/BlogPost.cs @@ -11,43 +11,43 @@ namespace MyBlog.Domain.Entities; public sealed class BlogPost { - public Guid Id { get; private set; } - public string Title { get; private set; } = string.Empty; - public string Content { get; private set; } = string.Empty; - public string Author { get; private set; } = string.Empty; - public DateTime CreatedAt { get; private set; } - public DateTime? UpdatedAt { get; private set; } - public bool IsPublished { get; private set; } - public int Version { get; private set; } + public Guid Id { get; private set; } + public string Title { get; private set; } = string.Empty; + public string Content { get; private set; } = string.Empty; + public string Author { get; private set; } = string.Empty; + public DateTime CreatedAt { get; private set; } + public DateTime? UpdatedAt { get; private set; } + public bool IsPublished { get; private set; } + public int Version { get; private set; } - private BlogPost() { } + private BlogPost() { } - public static BlogPost Create(string title, string content, string author) - { - ArgumentException.ThrowIfNullOrWhiteSpace(title); - ArgumentException.ThrowIfNullOrWhiteSpace(content); - ArgumentException.ThrowIfNullOrWhiteSpace(author); + public static BlogPost Create(string title, string content, string author) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(content); + ArgumentException.ThrowIfNullOrWhiteSpace(author); - return new BlogPost - { - Id = Guid.NewGuid(), - Title = title, - Content = content, - Author = author, - CreatedAt = DateTime.UtcNow, - }; - } + return new BlogPost + { + Id = Guid.NewGuid(), + Title = title, + Content = content, + Author = author, + CreatedAt = DateTime.UtcNow, + }; + } - public void Update(string title, string content) - { - ArgumentException.ThrowIfNullOrWhiteSpace(title); - ArgumentException.ThrowIfNullOrWhiteSpace(content); - Title = title; - Content = content; - UpdatedAt = DateTime.UtcNow; - Version++; - } + public void Update(string title, string content) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(content); + Title = title; + Content = content; + UpdatedAt = DateTime.UtcNow; + Version++; + } - public void Publish() => IsPublished = true; - public void Unpublish() => IsPublished = false; + public void Publish() => IsPublished = true; + public void Unpublish() => IsPublished = false; } diff --git a/src/Domain/Interfaces/IBlogPostRepository.cs b/src/Domain/Interfaces/IBlogPostRepository.cs index ecadec06..b784feb4 100644 --- a/src/Domain/Interfaces/IBlogPostRepository.cs +++ b/src/Domain/Interfaces/IBlogPostRepository.cs @@ -13,9 +13,9 @@ namespace MyBlog.Domain.Interfaces; public interface IBlogPostRepository { - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(BlogPost post, CancellationToken ct = default); - Task UpdateAsync(BlogPost post, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task AddAsync(BlogPost post, CancellationToken ct = default); + Task UpdateAsync(BlogPost post, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); } diff --git a/src/ServiceDefaults/Extensions.cs b/src/ServiceDefaults/Extensions.cs index 6636392d..559406a1 100644 --- a/src/ServiceDefaults/Extensions.cs +++ b/src/ServiceDefaults/Extensions.cs @@ -28,100 +28,100 @@ namespace MyBlog.ServiceDefaults; [ExcludeFromCodeCoverage(Justification = "Aspire infrastructure bootstrap — not business logic")] public static class ServiceDefaultsExtensions { - private const string HealthEndpointPath = "/health"; - private const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(tracing => - // Exclude health check requests from tracing - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath, System.StringComparison.OrdinalIgnoreCase) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath, System.StringComparison.OrdinalIgnoreCase) - ) - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath, System.StringComparison.OrdinalIgnoreCase) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath, System.StringComparison.OrdinalIgnoreCase) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } public static WebApplication MapDefaultEndpoints(this WebApplication app) { - ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(app); // Adding health checks endpoints to applications in non-development environments has security implications. // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. diff --git a/src/Web/Data/BlogPostDto.cs b/src/Web/Data/BlogPostDto.cs index 77cd7b11..37943351 100644 --- a/src/Web/Data/BlogPostDto.cs +++ b/src/Web/Data/BlogPostDto.cs @@ -10,10 +10,10 @@ namespace MyBlog.Web.Data; internal sealed record BlogPostDto( - Guid Id, - string Title, - string Content, - string Author, - DateTime CreatedAt, - DateTime? UpdatedAt, - bool IsPublished); + Guid Id, + string Title, + string Content, + string Author, + DateTime CreatedAt, + DateTime? UpdatedAt, + bool IsPublished); diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs index b9d0a1f2..bc0cafbe 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs @@ -16,28 +16,28 @@ internal sealed class CreateBlogPostHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler> { -public async Task> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -var post = BlogPost.Create(request.Title, request.Content, request.Author); -await repo.AddAsync(post, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -return Result.Ok(post.Id); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + var post = BlogPost.Create(request.Title, request.Content, request.Author); + await repo.AddAsync(post, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + return Result.Ok(post.Id); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs index ec45bd13..2dfef6bd 100644 --- a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs @@ -16,34 +16,34 @@ internal sealed class DeleteBlogPostHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler { -public async Task Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -await repo.DeleteAsync(request.Id, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (DbUpdateConcurrencyException) -{ -return Result.Fail( -"This post was modified by another user. Please reload and try again.", -ResultErrorCode.Concurrency); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + await repo.DeleteAsync(request.Id, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (DbUpdateConcurrencyException) + { + return Result.Fail( + "This post was modified by another user. Please reload and try again.", + ResultErrorCode.Concurrency); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs index 88278245..e7cd4c25 100644 --- a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs @@ -18,67 +18,67 @@ internal sealed class EditBlogPostHandler( : IRequestHandler, IRequestHandler> { -public async Task Handle(EditBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -if (post is null) -return Result.Fail($"BlogPost {request.Id} not found."); -post.Update(request.Title, request.Content); -await repo.UpdateAsync(post, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (DbUpdateConcurrencyException) -{ -return Result.Fail( -"This post was modified by another user. Please reload and try again.", -ResultErrorCode.Concurrency); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(EditBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (post is null) + return Result.Fail($"BlogPost {request.Id} not found."); + post.Update(request.Title, request.Content); + await repo.UpdateAsync(post, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (DbUpdateConcurrencyException) + { + return Result.Fail( + "This post was modified by another user. Please reload and try again.", + ResultErrorCode.Concurrency); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task> Handle(GetBlogPostByIdQuery request, CancellationToken cancellationToken) -{ -try -{ -var dto = await cache.GetOrFetchByIdAsync( -request.Id, -async () => -{ -var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return post?.ToDto(); -}, cancellationToken).ConfigureAwait(false); -return Result.Ok(dto); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task> Handle(GetBlogPostByIdQuery request, CancellationToken cancellationToken) + { + try + { + var dto = await cache.GetOrFetchByIdAsync( + request.Id, + async () => + { + var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return post?.ToDto(); + }, cancellationToken).ConfigureAwait(false); + return Result.Ok(dto); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs index 18276f74..601be879 100644 --- a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs +++ b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs @@ -16,32 +16,32 @@ internal sealed class GetBlogPostsHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler>> { -public async Task>> Handle( -GetBlogPostsQuery request, CancellationToken cancellationToken) -{ -try -{ -var result = await cache.GetOrFetchAllAsync( -async () => -{ -var all = await repo.GetAllAsync(cancellationToken).ConfigureAwait(false); -return (IReadOnlyList)all.Select(p => p.ToDto()).ToList(); -}, cancellationToken).ConfigureAwait(false); -return Result.Ok>(result); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle( + GetBlogPostsQuery request, CancellationToken cancellationToken) + { + try + { + var result = await cache.GetOrFetchAllAsync( + async () => + { + var all = await repo.GetAllAsync(cancellationToken).ConfigureAwait(false); + return (IReadOnlyList)all.Select(p => p.ToDto()).ToList(); + }, cancellationToken).ConfigureAwait(false); + return Result.Ok>(result); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/GlobalUsings.cs b/src/Web/GlobalUsings.cs index 551ae07b..8e4b29eb 100644 --- a/src/Web/GlobalUsings.cs +++ b/src/Web/GlobalUsings.cs @@ -8,9 +8,11 @@ //======================================================= global using MediatR; + global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.Caching.Distributed; global using Microsoft.Extensions.Caching.Memory; + global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; global using MyBlog.Web.Data; diff --git a/src/Web/Security/RoleClaimsHelper.cs b/src/Web/Security/RoleClaimsHelper.cs index 02895781..22241270 100644 --- a/src/Web/Security/RoleClaimsHelper.cs +++ b/src/Web/Security/RoleClaimsHelper.cs @@ -14,123 +14,123 @@ namespace MyBlog.Web.Security; internal static class RoleClaimsHelper { - public static readonly string[] DefaultRoleClaimTypes = - [ - "https://myblog/roles", - "roles", - "role" - ]; - - public static IReadOnlyList GetRoleClaimTypes(IConfiguration configuration) - { - var configured = configuration.GetSection("Auth0:RoleClaimTypes").Get(); - - return configured is { Length: > 0 } - ? configured.Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() - : DefaultRoleClaimTypes; - } - - public static bool IsRoleClaimType(string? claimType) - { - if (string.IsNullOrWhiteSpace(claimType)) - { - return false; - } - - if (claimType.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) - || claimType.Equals("roles", StringComparison.OrdinalIgnoreCase) - || claimType.Equals("role", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var lastSlash = claimType.LastIndexOf('/'); - var lastColon = claimType.LastIndexOf(':'); - var separatorIndex = Math.Max(lastSlash, lastColon); - var tail = separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; - - return tail.Equals("roles", StringComparison.OrdinalIgnoreCase) - || tail.Equals("role", StringComparison.OrdinalIgnoreCase); - } - - private static string[] GetEffectiveRoleClaimTypes(IEnumerable claims, IEnumerable? roleClaimTypes) - { - return (roleClaimTypes ?? DefaultRoleClaimTypes) - .Append(ClaimTypes.Role) - .Concat(claims.Select(claim => claim.Type).Where(IsRoleClaimType)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - public static IReadOnlyList ExpandRoleValues(string? claimValue) - { - if (string.IsNullOrWhiteSpace(claimValue)) - { - return []; - } - - var trimmed = claimValue.Trim(); - - if (trimmed.StartsWith("[", StringComparison.Ordinal)) - { - try - { - using var document = JsonDocument.Parse(trimmed); - - if (document.RootElement.ValueKind == JsonValueKind.Array) - { - return document.RootElement - .EnumerateArray() - .Select(element => element.GetString()) - .Where(role => !string.IsNullOrWhiteSpace(role)) - .Cast() - .ToArray(); - } - } - catch (JsonException) - { - return []; - } - } - - if (trimmed.Contains(',', StringComparison.Ordinal)) - { - return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - } - - return [trimmed]; - } - - public static void AddRoleClaims(ClaimsIdentity identity, IEnumerable roleClaimTypes) - { - ArgumentNullException.ThrowIfNull(identity); - - foreach (var roleClaimType in GetEffectiveRoleClaimTypes(identity.Claims, roleClaimTypes)) - { - foreach (var claim in identity.FindAll(roleClaimType).ToList()) - { - foreach (var role in ExpandRoleValues(claim.Value)) - { - if (!identity.HasClaim(ClaimTypes.Role, role)) - { - identity.AddClaim(new Claim(ClaimTypes.Role, role)); - } - } - } - } - } - - public static IReadOnlyList GetRoles(ClaimsPrincipal user, IEnumerable? roleClaimTypes = null) - { - var types = GetEffectiveRoleClaimTypes(user.Claims, roleClaimTypes); - - return user.Claims - .Where(claim => types.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) - .SelectMany(claim => ExpandRoleValues(claim.Value)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(role => role) - .ToList(); - } + public static readonly string[] DefaultRoleClaimTypes = + [ + "https://myblog/roles", + "roles", + "role" + ]; + + public static IReadOnlyList GetRoleClaimTypes(IConfiguration configuration) + { + var configured = configuration.GetSection("Auth0:RoleClaimTypes").Get(); + + return configured is { Length: > 0 } + ? configured.Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + : DefaultRoleClaimTypes; + } + + public static bool IsRoleClaimType(string? claimType) + { + if (string.IsNullOrWhiteSpace(claimType)) + { + return false; + } + + if (claimType.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) + || claimType.Equals("roles", StringComparison.OrdinalIgnoreCase) + || claimType.Equals("role", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var lastSlash = claimType.LastIndexOf('/'); + var lastColon = claimType.LastIndexOf(':'); + var separatorIndex = Math.Max(lastSlash, lastColon); + var tail = separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; + + return tail.Equals("roles", StringComparison.OrdinalIgnoreCase) + || tail.Equals("role", StringComparison.OrdinalIgnoreCase); + } + + private static string[] GetEffectiveRoleClaimTypes(IEnumerable claims, IEnumerable? roleClaimTypes) + { + return (roleClaimTypes ?? DefaultRoleClaimTypes) + .Append(ClaimTypes.Role) + .Concat(claims.Select(claim => claim.Type).Where(IsRoleClaimType)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static IReadOnlyList ExpandRoleValues(string? claimValue) + { + if (string.IsNullOrWhiteSpace(claimValue)) + { + return []; + } + + var trimmed = claimValue.Trim(); + + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + using var document = JsonDocument.Parse(trimmed); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement + .EnumerateArray() + .Select(element => element.GetString()) + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Cast() + .ToArray(); + } + } + catch (JsonException) + { + return []; + } + } + + if (trimmed.Contains(',', StringComparison.Ordinal)) + { + return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + return [trimmed]; + } + + public static void AddRoleClaims(ClaimsIdentity identity, IEnumerable roleClaimTypes) + { + ArgumentNullException.ThrowIfNull(identity); + + foreach (var roleClaimType in GetEffectiveRoleClaimTypes(identity.Claims, roleClaimTypes)) + { + foreach (var claim in identity.FindAll(roleClaimType).ToList()) + { + foreach (var role in ExpandRoleValues(claim.Value)) + { + if (!identity.HasClaim(ClaimTypes.Role, role)) + { + identity.AddClaim(new Claim(ClaimTypes.Role, role)); + } + } + } + } + } + + public static IReadOnlyList GetRoles(ClaimsPrincipal user, IEnumerable? roleClaimTypes = null) + { + var types = GetEffectiveRoleClaimTypes(user.Claims, roleClaimTypes); + + return user.Claims + .Where(claim => types.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) + .SelectMany(claim => ExpandRoleValues(claim.Value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(role => role) + .ToList(); + } } diff --git a/tests/AppHost.Tests/EnvVarTests.cs b/tests/AppHost.Tests/EnvVarTests.cs index 3a391be1..a201fa7f 100644 --- a/tests/AppHost.Tests/EnvVarTests.cs +++ b/tests/AppHost.Tests/EnvVarTests.cs @@ -8,6 +8,7 @@ // ============================================= using Aspire.Hosting; + using FluentAssertions; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs b/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs index f9c982e6..c352ccdc 100644 --- a/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs +++ b/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs @@ -7,9 +7,10 @@ // Project Name : AppHost.Tests // ============================================= -using Microsoft.Playwright; using System.Diagnostics; +using Microsoft.Playwright; + namespace AppHost.Tests.Infrastructure; /// diff --git a/tests/AppHost.Tests/MongoDbClearCommandTests.cs b/tests/AppHost.Tests/MongoDbClearCommandTests.cs index ae6bf17f..832cd326 100644 --- a/tests/AppHost.Tests/MongoDbClearCommandTests.cs +++ b/tests/AppHost.Tests/MongoDbClearCommandTests.cs @@ -25,159 +25,159 @@ namespace AppHost.Tests; /// public sealed class MongoDbClearCommandTests { -private const string CommandName = "clear-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 "clear-myblog-data" operator action. -/// -[Fact] -public async Task MongoDb_Resource_Exposes_ClearMyBlogData_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 'clear-myblog-data' operator action per issue #247"); -} - -/// -/// Acceptance criterion #2: The clear-myblog-data action must be marked as destructive so the -/// Aspire dashboard renders it with a danger indicator. -/// -[Fact] -public async Task ClearMyBlogData_Command_IsHighlighted_Marks_It_As_Destructive() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(builder); - -// Assert -annotation.IsHighlighted.Should().BeTrue( -"a destructive data-clearing command must set IsHighlighted = true so the Aspire dashboard warns the operator"); -} - -/// -/// Acceptance criterion #3: The action must require explicit y/n confirmation. -/// -/// Setting ConfirmationMessage on the annotation causes the Aspire dashboard to render -/// an OK/Cancel dialog before the execute callback is ever invoked. When the operator clicks -/// Cancel the callback is NOT called — this is the framework-managed no-op guarantee for a -/// declined confirmation. -/// -/// -[Fact] -public async Task ClearMyBlogData_Command_Has_ConfirmationMessage_Enabling_Yn_Prompt() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(builder); - -// Assert -annotation.ConfirmationMessage.Should().NotBeNullOrEmpty( -"the command must define a ConfirmationMessage so Aspire shows a y/n dialog; " -+ "declining that dialog is the built-in no-op guarantee"); -} - -/// -/// Acceptance criterion #4: The action is enabled only when MongoDB is healthy. -/// -[Fact] -public async Task ClearMyBlogData_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(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 clear-myblog-data command must be available when MongoDB is healthy"); -} - -/// -/// Acceptance criterion #4 (corollary): The action must be disabled when MongoDB is not healthy -/// to prevent destructive operations on an unstable or stopped container. -/// -[Fact] -public async Task ClearMyBlogData_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(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 clear-myblog-data command must be unavailable when MongoDB is unhealthy"); -} - - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -private static ResourceCommandAnnotation GetClearMyBlogDataAnnotation(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); + private const string CommandName = "clear-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 "clear-myblog-data" operator action. + /// + [Fact] + public async Task MongoDb_Resource_Exposes_ClearMyBlogData_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 'clear-myblog-data' operator action per issue #247"); + } + + /// + /// Acceptance criterion #2: The clear-myblog-data action must be marked as destructive so the + /// Aspire dashboard renders it with a danger indicator. + /// + [Fact] + public async Task ClearMyBlogData_Command_IsHighlighted_Marks_It_As_Destructive() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(builder); + + // Assert + annotation.IsHighlighted.Should().BeTrue( + "a destructive data-clearing command must set IsHighlighted = true so the Aspire dashboard warns the operator"); + } + + /// + /// Acceptance criterion #3: The action must require explicit y/n confirmation. + /// + /// Setting ConfirmationMessage on the annotation causes the Aspire dashboard to render + /// an OK/Cancel dialog before the execute callback is ever invoked. When the operator clicks + /// Cancel the callback is NOT called — this is the framework-managed no-op guarantee for a + /// declined confirmation. + /// + /// + [Fact] + public async Task ClearMyBlogData_Command_Has_ConfirmationMessage_Enabling_Yn_Prompt() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(builder); + + // Assert + annotation.ConfirmationMessage.Should().NotBeNullOrEmpty( + "the command must define a ConfirmationMessage so Aspire shows a y/n dialog; " + + "declining that dialog is the built-in no-op guarantee"); + } + + /// + /// Acceptance criterion #4: The action is enabled only when MongoDB is healthy. + /// + [Fact] + public async Task ClearMyBlogData_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(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 clear-myblog-data command must be available when MongoDB is healthy"); + } + + /// + /// Acceptance criterion #4 (corollary): The action must be disabled when MongoDB is not healthy + /// to prevent destructive operations on an unstable or stopped container. + /// + [Fact] + public async Task ClearMyBlogData_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(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 clear-myblog-data command must be unavailable when MongoDB is unhealthy"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static ResourceCommandAnnotation GetClearMyBlogDataAnnotation(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)! @@ -187,6 +187,6 @@ private static CustomResourceSnapshot BuildSnapshot(HealthStatus health) .GetSetMethod(nonPublic: true)! .Invoke(snapshot, [(HealthStatus?)health]); -return snapshot; -} + return snapshot; + } } diff --git a/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs b/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs index 0a47403f..a43f0696 100644 --- a/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs @@ -8,7 +8,9 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; + using Microsoft.Playwright; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs b/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs index b868b0fb..9d42069f 100644 --- a/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs +++ b/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs @@ -8,7 +8,9 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; + using Microsoft.Playwright; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs b/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs index f06c00a6..2f3a1309 100644 --- a/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs +++ b/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs @@ -8,7 +8,9 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; + using Microsoft.Playwright; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/WebPlaywrightTests.cs b/tests/AppHost.Tests/WebPlaywrightTests.cs index 53028007..d42526fb 100644 --- a/tests/AppHost.Tests/WebPlaywrightTests.cs +++ b/tests/AppHost.Tests/WebPlaywrightTests.cs @@ -8,6 +8,7 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; namespace AppHost.Tests; diff --git a/tests/Architecture.Tests/GlobalUsings.cs b/tests/Architecture.Tests/GlobalUsings.cs index f1f7b3c8..bf75d1b4 100644 --- a/tests/Architecture.Tests/GlobalUsings.cs +++ b/tests/Architecture.Tests/GlobalUsings.cs @@ -8,5 +8,7 @@ //======================================================= global using FluentAssertions; + global using MyBlog.Domain.Entities; + global using NetArchTest.Rules; diff --git a/tests/Domain.Tests/GlobalUsings.cs b/tests/Domain.Tests/GlobalUsings.cs index 6194df10..dea26d27 100644 --- a/tests/Domain.Tests/GlobalUsings.cs +++ b/tests/Domain.Tests/GlobalUsings.cs @@ -8,10 +8,14 @@ //======================================================= global using FluentAssertions; + global using FluentValidation; global using FluentValidation.Results; + global using MediatR; + global using MyBlog.Domain.Abstractions; global using MyBlog.Domain.Behaviors; global using MyBlog.Domain.Entities; + global using NSubstitute; diff --git a/tests/Web.Tests.Bunit/GlobalUsings.cs b/tests/Web.Tests.Bunit/GlobalUsings.cs index 840408b5..c245cc6c 100644 --- a/tests/Web.Tests.Bunit/GlobalUsings.cs +++ b/tests/Web.Tests.Bunit/GlobalUsings.cs @@ -7,16 +7,20 @@ //Project Name : Web.Tests.Bunit //======================================================= +global using System.Security.Claims; + global using Bunit; global using FluentAssertions; + global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Components.Authorization; global using Microsoft.Extensions.Caching.Distributed; global using Microsoft.Extensions.Caching.Memory; + global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; global using MyBlog.Web.Data; + global using NSubstitute; global using NSubstitute.ExceptionExtensions; -global using System.Security.Claims; diff --git a/tests/Web.Tests/GlobalUsings.cs b/tests/Web.Tests/GlobalUsings.cs index c88df296..88d19831 100644 --- a/tests/Web.Tests/GlobalUsings.cs +++ b/tests/Web.Tests/GlobalUsings.cs @@ -7,15 +7,19 @@ //Project Name : Web.Tests //======================================================= +global using System.Security.Claims; + global using FluentAssertions; + global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Components.Authorization; global using Microsoft.Extensions.Caching.Distributed; global using Microsoft.Extensions.Caching.Memory; + global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; global using MyBlog.Web.Data; global using MyBlog.Web.Infrastructure.Caching; + global using NSubstitute; global using NSubstitute.ExceptionExtensions; -global using System.Security.Claims;