diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/alpha-release.yml index db00492..625c5f5 100644 --- a/.github/workflows/alpha-release.yml +++ b/.github/workflows/alpha-release.yml @@ -119,11 +119,16 @@ jobs: echo "version=$ALPHA_VERSION" >> $GITHUB_OUTPUT echo "Generated version: $ALPHA_VERSION" - - name: Pack + - name: Pack CLI env: VERSION: ${{ steps.version.outputs.version }} run: dotnet pack src/ServiceBusToolset.CLI -c Release --no-build -o ./artifacts -p:Version="$VERSION" + - name: Pack Application + env: + VERSION: ${{ steps.version.outputs.version }} + run: dotnet pack src/ServiceBusToolset.Application -c Release --no-build -o ./artifacts -p:Version="$VERSION" + - name: Push to NuGet env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} @@ -133,3 +138,4 @@ jobs: exit 0 fi dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push ./artifacts/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index b927e56..7bcc43e 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -63,11 +63,16 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Releasing version: $VERSION" - - name: Pack + - name: Pack CLI env: VERSION: ${{ steps.version.outputs.version }} run: dotnet pack src/ServiceBusToolset.CLI -c Release --no-build -o ./artifacts -p:Version="$VERSION" + - name: Pack Application + env: + VERSION: ${{ steps.version.outputs.version }} + run: dotnet pack src/ServiceBusToolset.Application -c Release --no-build -o ./artifacts -p:Version="$VERSION" + - name: Generate SBOM env: VERSION: ${{ steps.version.outputs.version }} @@ -91,6 +96,7 @@ jobs: exit 1 fi dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push ./artifacts/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate - name: Upload package to GitHub Release uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 diff --git a/docs/integration-tests/integration_test_strategy.md b/docs/integration-tests/integration_test_strategy.md index bc8c8db..517949c 100644 --- a/docs/integration-tests/integration_test_strategy.md +++ b/docs/integration-tests/integration_test_strategy.md @@ -172,7 +172,7 @@ Each test instance builds a fresh `ServiceCollection`. This mirrors how the prod var services = new ServiceCollection(); // 1. Register the full Application layer — Mediator pipeline, DlqMessageService, IAppInsightsService - services.AddApplication(); + services.AddServiceBusToolsetApplication(); // 2. Replace the client factory with the emulator-backed implementation services.AddSingleton( @@ -384,7 +384,7 @@ public class DiagnoseDlqIntegrationShould(ServiceBusEmulatorFixture fixture) } ``` -**How DI override order works:** `AddApplication()` registers `IAppInsightsService` as scoped (via `services.AddScoped()`). The `configureServices` delegate runs *after* `AddApplication()` and registers an NSubstitute singleton for the same interface. Microsoft's DI container resolves the *last* registration for a given service type, so the mock wins. The real `AppInsightsService` (which requires Azure credentials) is never instantiated. +**How DI override order works:** `AddServiceBusToolsetApplication()` registers `IAppInsightsService` as scoped (via `services.AddScoped()`). The `configureServices` delegate runs *after* `AddServiceBusToolsetApplication()` and registers an NSubstitute singleton for the same interface. Microsoft's DI container resolves the *last* registration for a given service type, so the mock wins. The real `AppInsightsService` (which requires Azure credentials) is never instantiated. ### Complete Test Example diff --git a/src/ServiceBusToolset.Application/ApplicationDependencyInjectionExtensions.cs b/src/ServiceBusToolset.Application/ApplicationDependencyInjectionExtensions.cs index 8b866e2..faaa2e3 100644 --- a/src/ServiceBusToolset.Application/ApplicationDependencyInjectionExtensions.cs +++ b/src/ServiceBusToolset.Application/ApplicationDependencyInjectionExtensions.cs @@ -6,7 +6,7 @@ namespace ServiceBusToolset.Application; public static class ApplicationDependencyInjectionExtensions { - public static IServiceCollection AddApplication(this IServiceCollection services) + public static IServiceCollection AddServiceBusToolsetApplication(this IServiceCollection services) { services.AddMediator(options => { diff --git a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/Common/MessageDiagnostics.cs b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/Common/MessageDiagnostics.cs index 3eac3f3..1b76cc3 100644 --- a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/Common/MessageDiagnostics.cs +++ b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/Common/MessageDiagnostics.cs @@ -58,6 +58,25 @@ internal static class MessageDiagnostics return (results, skipped); } + public static List CreateBasicResults(IReadOnlyList messages) + { + var results = new List(messages.Count); + foreach (var message in messages) + { + results.Add(new DiagnosticResult + { + MessageId = message.MessageId, + Subject = message.Subject, + DeadLetterReason = message.DeadLetterReason, + Body = TryDecodeBody(message), + EnqueuedTime = message.EnqueuedTime, + OperationId = ExtractOperationId(message) + }); + } + + return results; + } + public static string? ExtractOperationId(ServiceBusReceivedMessage message) { if (message.ApplicationProperties.TryGetValue("Diagnostic-Id", out var diagnosticId) && diff --git a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseBatchCommand.cs b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseBatchCommand.cs new file mode 100644 index 0000000..7af8af5 --- /dev/null +++ b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseBatchCommand.cs @@ -0,0 +1,14 @@ +using Ardalis.Result; +using Mediator; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models; + +namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq; + +public sealed record DiagnoseBatchCommand(string AppInsightsResourceId, + IReadOnlyList Operations) : ICommand>>; + +public sealed record OperationInfo(string OperationId, + DateTimeOffset EnqueuedTime, + string? MessageId, + string? Subject, + string? DeadLetterReason); diff --git a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseBatchCommandHandler.cs b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseBatchCommandHandler.cs new file mode 100644 index 0000000..6cefb14 --- /dev/null +++ b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseBatchCommandHandler.cs @@ -0,0 +1,49 @@ +using Ardalis.Result; +using Mediator; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models; + +namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq; + +public sealed class DiagnoseBatchCommandHandler(IAppInsightsService appInsightsService) + : ICommandHandler>> +{ + public async ValueTask>> Handle( + DiagnoseBatchCommand command, + CancellationToken cancellationToken) + { + if (command.Operations.Count == 0) + { + return Result.Success>([]); + } + + appInsightsService.Initialize(command.AppInsightsResourceId); + + var operations = command.Operations + .Select(op => (op.OperationId, op.EnqueuedTime)) + .ToList(); + + var diagnosticResults = await appInsightsService.DiagnoseBatchAsync(operations, + null, + cancellationToken); + + var results = new List(); + // Deduplicate by OperationId — take the first occurrence if duplicates exist + var operationsById = command.Operations + .DistinctBy(op => op.OperationId) + .ToDictionary(op => op.OperationId); + + foreach (var (operationId, result) in diagnosticResults) + { + if (operationsById.TryGetValue(operationId, out var operation)) + { + result.MessageId = operation.MessageId; + result.Subject = operation.Subject; + result.DeadLetterReason = operation.DeadLetterReason; + results.Add(result); + } + } + + return Result.Success>(results); + } +} diff --git a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommand.cs b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommand.cs index 63862c0..a28b689 100644 --- a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommand.cs +++ b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommand.cs @@ -7,7 +7,7 @@ namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq; public sealed record DiagnoseDlqCommand(string FullyQualifiedNamespace, EntityTarget Target, - string AppInsightsResourceId, + string? AppInsightsResourceId, int MaxMessages, DateTimeOffset? BeforeTime = null, IReadOnlySet? CategoryFilter = null, diff --git a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs index fa20b9a..253a6de 100644 --- a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs +++ b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs @@ -5,6 +5,7 @@ using ServiceBusToolset.Application.DeadLetters.Common; using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common; using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models; namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq; @@ -28,8 +29,10 @@ public async ValueTask> Handle( DiagnoseDlqCommand command, CancellationToken cancellationToken) { - // Initialize Application Insights connection - appInsightsService.Initialize(command.AppInsightsResourceId); + if (!string.IsNullOrEmpty(command.AppInsightsResourceId)) + { + appInsightsService.Initialize(command.AppInsightsResourceId); + } await using var client = clientFactory.CreateClient(command.FullyQualifiedNamespace); await using var receiver = ReceiverFactory.CreateDlqReceiver(client, command.Target); @@ -60,11 +63,21 @@ public async ValueTask> Handle( 0)); } - // Diagnose messages - var (results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService, + List results; + int skipped; + + if (string.IsNullOrEmpty(command.AppInsightsResourceId)) + { + results = MessageDiagnostics.CreateBasicResults(filteredMessages); + skipped = 0; + } + else + { + (results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService, filteredMessages, command.BatchProgress, cancellationToken); + } var resultsWithTelemetry = results .Count(r => r.Exceptions.Count > 0 || r.Traces.Count > 0 || r.FailedDependencies.Count > 0); diff --git a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseFromCache.cs b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseFromCache.cs index 0d7cc2e..32f6d8e 100644 --- a/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseFromCache.cs +++ b/src/ServiceBusToolset.Application/DeadLetters/DiagnoseDlq/DiagnoseFromCache.cs @@ -3,10 +3,11 @@ using Mediator; using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common; using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models; namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq; -public sealed record DiagnoseFromCacheCommand(string AppInsightsResourceId, +public sealed record DiagnoseFromCacheCommand(string? AppInsightsResourceId, IReadOnlyList MessagesToDiagnose, IProgress<(int Current, int Total)>? BatchProgress = null) : ICommand>; @@ -17,7 +18,10 @@ public async ValueTask> Handle( DiagnoseFromCacheCommand command, CancellationToken cancellationToken) { - appInsightsService.Initialize(command.AppInsightsResourceId); + if (!string.IsNullOrEmpty(command.AppInsightsResourceId)) + { + appInsightsService.Initialize(command.AppInsightsResourceId); + } var messages = command.MessagesToDiagnose.ToList(); @@ -29,10 +33,21 @@ public async ValueTask> Handle( 0)); } - var (results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService, + List results; + int skipped; + + if (string.IsNullOrEmpty(command.AppInsightsResourceId)) + { + results = MessageDiagnostics.CreateBasicResults(messages); + skipped = 0; + } + else + { + (results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService, messages, command.BatchProgress, cancellationToken); + } var resultsWithTelemetry = results .Count(r => r.Exceptions.Count > 0 || r.Traces.Count > 0 || r.FailedDependencies.Count > 0); diff --git a/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchCommand.cs b/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchCommand.cs new file mode 100644 index 0000000..4eadc78 --- /dev/null +++ b/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchCommand.cs @@ -0,0 +1,11 @@ +using Ardalis.Result; +using Mediator; +using ServiceBusToolset.Application.Common.ServiceBus.Models; + +namespace ServiceBusToolset.Application.DeadLetters.PeekDlq; + +public sealed record PeekDlqBatchCommand(string FullyQualifiedNamespace, + EntityTarget Target, + int BatchSize = 500, + long? FromSequenceNumber = null, + long? KnownDeadLetterCount = null) : ICommand>; diff --git a/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchCommandHandler.cs b/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchCommandHandler.cs new file mode 100644 index 0000000..835ebe9 --- /dev/null +++ b/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchCommandHandler.cs @@ -0,0 +1,154 @@ +using Ardalis.Result; +using Azure; +using Azure.Messaging.ServiceBus; +using Mediator; +using ServiceBusToolset.Application.Common.ServiceBus.Abstractions; +using ServiceBusToolset.Application.Common.ServiceBus.Helpers; +using ServiceBusToolset.Application.Common.ServiceBus.Models; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common; + +namespace ServiceBusToolset.Application.DeadLetters.PeekDlq; + +public sealed class PeekDlqBatchCommandHandler(IServiceBusClientFactory clientFactory) + : ICommandHandler> +{ + private const int PeekSubBatchSize = 100; + private const int EmptyBatchThreshold = 3; + + public async ValueTask> Handle( + PeekDlqBatchCommand command, + CancellationToken cancellationToken) + { + await using var client = clientFactory.CreateClient(command.FullyQualifiedNamespace); + + // Get total DLQ count — use known count if provided, otherwise query admin API + var totalDeadLetterCount = command.KnownDeadLetterCount + ?? await GetDeadLetterCountAsync(clientFactory, command.FullyQualifiedNamespace, command.Target); + + // Peek messages in sub-batches until we reach BatchSize or run out + await using var receiver = ReceiverFactory.CreateDlqReceiver(client, command.Target); + + List allMessages = []; + var emptyBatches = 0; + var isFirstPeek = true; + var highestSequenceNumber = command.FromSequenceNumber ?? -1; + + while (allMessages.Count < command.BatchSize && + emptyBatches < EmptyBatchThreshold && + !cancellationToken.IsCancellationRequested) + { + var remaining = command.BatchSize - allMessages.Count; + var subBatchSize = Math.Min(PeekSubBatchSize, remaining); + + IReadOnlyList batch; + if (isFirstPeek && command.FromSequenceNumber.HasValue) + { + batch = await receiver.PeekMessagesAsync(subBatchSize, command.FromSequenceNumber.Value + 1, cancellationToken); + isFirstPeek = false; + } + else + { + batch = await receiver.PeekMessagesAsync(subBatchSize, cancellationToken:cancellationToken); + isFirstPeek = false; + } + + if (batch.Count == 0) + { + emptyBatches++; + continue; + } + + // Detect wrap-around: if the batch contains messages with sequence numbers + // we've already passed, the receiver has looped back to the beginning + if (batch[0].SequenceNumber <= highestSequenceNumber) + { + break; + } + + emptyBatches = 0; + allMessages.AddRange(batch); + highestSequenceNumber = batch[^1].SequenceNumber; + } + + if (allMessages.Count == 0) + { + return Result.Success(new PeekDlqBatchResult([], + 0, + 0, + command.FromSequenceNumber, + false, + totalDeadLetterCount)); + } + + // Extract operation IDs + List messages = []; + var skipped = 0; + HashSet seenOperationIds = []; + + foreach (var message in allMessages) + { + var operationId = MessageDiagnostics.ExtractOperationId(message); + if (string.IsNullOrEmpty(operationId)) + { + skipped++; + continue; + } + + if (seenOperationIds.Add(operationId)) + { + messages.Add(new PeekedMessage(message.MessageId, + message.Subject, + operationId, + message.EnqueuedTime, + message.DeadLetterReason)); + } + } + + var lastSequenceNumber = allMessages[^1].SequenceNumber; + // We have more messages if we filled the batch (didn't run out early) + var hasMore = allMessages.Count >= command.BatchSize && emptyBatches < EmptyBatchThreshold; + + // Fallback for environments where the admin API doesn't report accurate DLQ counts (e.g., emulator). + // When we've consumed the entire queue in one pass (!hasMore), the peek count is the ground truth. + if (totalDeadLetterCount == 0 && !hasMore) + { + totalDeadLetterCount = allMessages.Count; + } + + return Result.Success(new PeekDlqBatchResult(messages, + allMessages.Count, + skipped, + lastSequenceNumber, + hasMore, + totalDeadLetterCount)); + } + + private static async Task GetDeadLetterCountAsync( + IServiceBusClientFactory clientFactory, + string fullyQualifiedNamespace, + EntityTarget target) + { + try + { + var adminClient = clientFactory.CreateAdministrationClient(fullyQualifiedNamespace); + + if (target.IsQueueMode) + { + var props = await adminClient.GetQueueRuntimePropertiesAsync(target.Queue!); + return props.Value.DeadLetterMessageCount; + } + + var subProps = await adminClient.GetSubscriptionRuntimePropertiesAsync(target.Topic!, target.Subscription!); + return subProps.Value.DeadLetterMessageCount; + } + catch (RequestFailedException) + { + // Admin API may not be available in all environments (e.g., emulator, unit tests) + return 0; + } + catch (OperationCanceledException) + { + return 0; + } + } +} diff --git a/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchResult.cs b/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchResult.cs new file mode 100644 index 0000000..07aab95 --- /dev/null +++ b/src/ServiceBusToolset.Application/DeadLetters/PeekDlq/PeekDlqBatchResult.cs @@ -0,0 +1,14 @@ +namespace ServiceBusToolset.Application.DeadLetters.PeekDlq; + +public sealed record PeekDlqBatchResult(IReadOnlyList Messages, + int PeekedInBatch, + int SkippedNoOperationId, + long? LastSequenceNumber, + bool HasMoreMessages, + long TotalDeadLetterCount); + +public sealed record PeekedMessage(string? MessageId, + string? Subject, + string OperationId, + DateTimeOffset EnqueuedTime, + string? DeadLetterReason); diff --git a/src/ServiceBusToolset.Application/ServiceBusToolset.Application.csproj b/src/ServiceBusToolset.Application/ServiceBusToolset.Application.csproj index 9731956..c57a35d 100644 --- a/src/ServiceBusToolset.Application/ServiceBusToolset.Application.csproj +++ b/src/ServiceBusToolset.Application/ServiceBusToolset.Application.csproj @@ -1,5 +1,31 @@ + + + ServiceBusToolset.Application + Gagik Kyurkchyan + Application layer for Azure Service Bus diagnostics: dead letter queue analysis, Application Insights correlation, and message inspection. + azure;service-bus;dead-letter-queue;diagnostics;application-insights + MIT + https://github.com/kyurkchyan/ServiceBusToolset + https://github.com/kyurkchyan/ServiceBusToolset.git + git + README.md + + + 1.0.0 + + + true + true + true + snupkg + + + + + + @@ -13,6 +39,7 @@ + diff --git a/src/ServiceBusToolset.CLI/DeadLetters/Common/DlqScanSessionExtensions.cs b/src/ServiceBusToolset.CLI/DeadLetters/Common/DlqScanSessionExtensions.cs index 2733369..be3c95e 100644 --- a/src/ServiceBusToolset.CLI/DeadLetters/Common/DlqScanSessionExtensions.cs +++ b/src/ServiceBusToolset.CLI/DeadLetters/Common/DlqScanSessionExtensions.cs @@ -21,6 +21,20 @@ public static async Task RunScanningPhaseAsync( IConsoleOutput output, string entityDescription) { + // LiveDisplay manipulates the console cursor handle, which throws IOException + // when stdout is redirected (e.g., test runners, CI pipelines). + // In that case, skip the live UI and just let the scan run to completion. + if (Console.IsOutputRedirected) + { + using (session.CategoryStream.Subscribe(_ => { })) + { + await session.ScanCompletion.Task; + session.StopScanning(); + } + + return; + } + DlqCategorySnapshot latestSnapshot = new([], 0, false, diff --git a/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCliCommand.cs b/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCliCommand.cs index 05f796e..492118f 100644 --- a/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCliCommand.cs +++ b/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCliCommand.cs @@ -32,9 +32,8 @@ public class DiagnoseDlqCliCommand : ICliCommand [Option('a', "app-insights", - Required = true, - HelpText = "Application Insights resource ID (e.g., /subscriptions/.../resourceGroups/.../providers/microsoft.insights/components/...)")] - public required string AppInsightsResourceId { get; set; } + HelpText = "Application Insights resource ID (e.g., /subscriptions/.../resourceGroups/.../providers/microsoft.insights/components/...). If omitted, basic diagnostic using dead letter reasons is performed.")] + public string? AppInsightsResourceId { get; set; } [Option('o', "output", diff --git a/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs b/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs index 66a4a7f..6876cae 100644 --- a/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs +++ b/src/ServiceBusToolset.CLI/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandler.cs @@ -30,8 +30,15 @@ protected override async Task> ExecuteCoreAsync( var target = CreateTarget(command); var entityDescription = target.GetDescription(); - Output.Info("Connecting to Application Insights..."); - Output.Verbose($"Connected to App Insights: {command.AppInsightsResourceId}", verbose); + if (!string.IsNullOrEmpty(command.AppInsightsResourceId)) + { + Output.Info("Connecting to Application Insights..."); + Output.Verbose($"App Insights resource: {command.AppInsightsResourceId}", verbose); + } + else + { + Output.Warning("No App Insights resource specified — basic diagnostic mode (dead letter reasons only)."); + } if (command.Interactive) { @@ -157,6 +164,23 @@ private void OutputDiagnoseResults(DiagnoseDlqResult result, DiagnoseDlqCliComma return; } + var basicMode = string.IsNullOrEmpty(cliCommand.AppInsightsResourceId); + + if (basicMode) + { + Output.Info($"Analyzed {result.TotalProcessed} messages (basic mode — no App Insights)"); + PrintBasicDiagnosticSummary(result.Results); + + if (!string.IsNullOrEmpty(cliCommand.OutputFile)) + { + var json = JsonSerializer.Serialize(result.Results, JsonOptions); + File.WriteAllText(cliCommand.OutputFile, json); + Output.Success($"Full diagnostic results written to '{cliCommand.OutputFile}'"); + } + + return; + } + Output.Info($"Queried App Insights for {result.TotalProcessed - result.SkippedNoOperationId} messages (skipped {result.SkippedNoOperationId} without operation ID)"); var resultsWithTelemetry = result.Results @@ -185,6 +209,45 @@ private void OutputDiagnoseResults(DiagnoseDlqResult result, DiagnoseDlqCliComma } } + private void PrintBasicDiagnosticSummary(IReadOnlyCollection results) + { + Output.Info(""); + Output.Info("Basic Diagnostic Summary (Dead Letter Reasons):"); + Output.Info("================================================"); + + var byReason = results + .GroupBy(r => r.DeadLetterReason ?? "(none)") + .OrderByDescending(g => g.Count()) + .ToList(); + + var headers = new[] + { + "Count", + "Dead Letter Reason", + "Subjects (sample)" + }; + var rows = byReason.Select(g => + { + var subjects = g + .Select(r => r.Subject ?? "(none)") + .Where(s => s != "(none)") + .Distinct() + .Take(3) + .ToList(); + var subjectSample = subjects.Count > 0 + ? string.Join(", ", subjects) + : "(none)"; + return new[] + { + g.Count().ToString(), + g.Key, + subjectSample + }; + }); + + Output.Table(headers, rows); + } + private void PrintDiagnosticSummary(IReadOnlyCollection results) { Output.Info(""); diff --git a/src/ServiceBusToolset.CLI/Program.cs b/src/ServiceBusToolset.CLI/Program.cs index cab5db2..835814f 100644 --- a/src/ServiceBusToolset.CLI/Program.cs +++ b/src/ServiceBusToolset.CLI/Program.cs @@ -11,7 +11,7 @@ builder.Services .AddSingleton(new CommandLineArguments(args)) - .AddApplication() + .AddServiceBusToolsetApplication() .AddCli() .AddCommandHandlers() .AddHostedService(); diff --git a/tests/ServiceBusToolset.Application.Tests/Common/Mocks/MockServiceBusClientFactory.cs b/tests/ServiceBusToolset.Application.Tests/Common/Mocks/MockServiceBusClientFactory.cs index 85eaa08..1fa9e39 100644 --- a/tests/ServiceBusToolset.Application.Tests/Common/Mocks/MockServiceBusClientFactory.cs +++ b/tests/ServiceBusToolset.Application.Tests/Common/Mocks/MockServiceBusClientFactory.cs @@ -1,6 +1,8 @@ +using Azure; using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus.Administration; using NSubstitute; +using NSubstitute.ExceptionExtensions; using ServiceBusToolset.Application.Common.ServiceBus.Abstractions; namespace ServiceBusToolset.Application.Tests.Common.Mocks; @@ -34,6 +36,12 @@ private MockServiceBusClientFactory() Factory.CreateClient(Arg.Any()).Returns(Client); Factory.CreateAdministrationClient(Arg.Any()).Returns(AdminClient); + // Admin API is unavailable in tests (mirrors emulator behavior) + AdminClient.GetQueueRuntimePropertiesAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new RequestFailedException("Admin API not available")); + AdminClient.GetSubscriptionRuntimePropertiesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new RequestFailedException("Admin API not available")); + // Setup client dispose Client.DisposeAsync().Returns(ValueTask.CompletedTask); } @@ -86,19 +94,14 @@ private void ConfigureReceiver() _peekCallCount = 0; _receiveCallCount = 0; - // Setup PeekMessagesAsync - returns all messages on first call, empty on subsequent + // Setup PeekMessagesAsync - returns messages in pages, respecting maxMessages per call Receiver.PeekMessagesAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => { var maxMessages = callInfo.ArgAt(0); - _peekCallCount++; - if (_peekCallCount == 1) - { - var batch = _messagesToReturn.Take(maxMessages).ToList(); - return Task.FromResult>(batch); - } - - return Task.FromResult>([]); + var batch = _messagesToReturn.Skip(_peekCallCount).Take(maxMessages).ToList(); + _peekCallCount += batch.Count; + return Task.FromResult>(batch); }); // Setup ReceiveMessagesAsync - returns all messages on first call, empty on subsequent diff --git a/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseBatchCommandHandlerShould.cs b/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseBatchCommandHandlerShould.cs new file mode 100644 index 0000000..0e593c5 --- /dev/null +++ b/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseBatchCommandHandlerShould.cs @@ -0,0 +1,190 @@ +using NSubstitute; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models; +using Shouldly; +using Xunit; + +namespace ServiceBusToolset.Application.Tests.DeadLetters.DiagnoseDlq; + +public class DiagnoseBatchCommandHandlerShould +{ + private readonly IAppInsightsService _mockAppInsights; + private readonly DiagnoseBatchCommandHandler _handler; + + public DiagnoseBatchCommandHandlerShould() + { + _mockAppInsights = Substitute.For(); + _handler = new DiagnoseBatchCommandHandler(_mockAppInsights); + } + + [Fact] + public async Task ReturnEmptyResult_WhenNoOperations() + { + var command = new DiagnoseBatchCommand("test-resource", []); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } + + [Fact] + public async Task InitializeAppInsightsService() + { + var operations = new List + { + new("op-1", + DateTimeOffset.UtcNow, + "msg-1", + "Subject1", + "Reason1") + }; + + SetupAppInsightsResponse("op-1"); + + var command = new DiagnoseBatchCommand("my-app-insights-resource", operations); + await _handler.Handle(command, CancellationToken.None); + + _mockAppInsights.Received(1).Initialize("my-app-insights-resource"); + } + + [Fact] + public async Task ReturnDiagnosticResults_WithEnrichedMessageInfo() + { + var operationId = "abc123def456abc123def456abc12345"; + var enqueuedTime = DateTimeOffset.UtcNow; + var operations = new List + { + new(operationId, + enqueuedTime, + "msg-1", + "OrderCreated", + "MaxDeliveryCountExceeded") + }; + + var appInsightsResults = new Dictionary + { + [operationId] = new() + { + OperationId = operationId, + Exceptions = + [ + new ExceptionInfo + { + ExceptionType = "TestException", + OuterMessage = "Something failed" + } + ] + } + }; + + _mockAppInsights.DiagnoseBatchAsync(Arg.Any>(), + Arg.Any?>(), + Arg.Any()) + .Returns(appInsightsResults); + + var command = new DiagnoseBatchCommand("test-resource", operations); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Count.ShouldBe(1); + + var diagnostic = result.Value[0]; + diagnostic.MessageId.ShouldBe("msg-1"); + diagnostic.Subject.ShouldBe("OrderCreated"); + diagnostic.DeadLetterReason.ShouldBe("MaxDeliveryCountExceeded"); + diagnostic.Exceptions.Count.ShouldBe(1); + diagnostic.Exceptions[0].ExceptionType.ShouldBe("TestException"); + } + + [Fact] + public async Task HandleMultipleOperations() + { + var operations = new List + { + new("op-1", + DateTimeOffset.UtcNow, + "msg-1", + "Subject1", + "Reason1"), + new("op-2", + DateTimeOffset.UtcNow, + "msg-2", + "Subject2", + "Reason2"), + new("op-3", + DateTimeOffset.UtcNow, + "msg-3", + "Subject3", + "Reason3") + }; + + var appInsightsResults = new Dictionary + { + ["op-1"] = new() + { + OperationId = "op-1", + Exceptions = [new ExceptionInfo { ExceptionType = "Ex1" }] + }, + ["op-2"] = new() { OperationId = "op-2" }, + ["op-3"] = new() + { + OperationId = "op-3", + FailedDependencies = [new DependencyInfo { Type = "HTTP" }] + } + }; + + _mockAppInsights.DiagnoseBatchAsync(Arg.Any>(), + Arg.Any?>(), + Arg.Any()) + .Returns(appInsightsResults); + + var command = new DiagnoseBatchCommand("test-resource", operations); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Count.ShouldBe(3); + result.Value.First(r => r.MessageId == "msg-1").Exceptions.Count.ShouldBe(1); + result.Value.First(r => r.MessageId == "msg-3").FailedDependencies.Count.ShouldBe(1); + } + + [Fact] + public async Task PassCorrectOperationsToAppInsights() + { + var enqueuedTime = new DateTimeOffset(2026, + 3, + 20, + 8, + 0, + 0, + TimeSpan.Zero); + var operations = new List + { + new("op-abc", + enqueuedTime, + "msg-1", + "Subject1", + "Reason1") + }; + + SetupAppInsightsResponse("op-abc"); + + var command = new DiagnoseBatchCommand("test-resource", operations); + await _handler.Handle(command, CancellationToken.None); + + await _mockAppInsights.Received(1).DiagnoseBatchAsync(Arg.Is>(ops => ops.Count == 1 && ops[0].OperationId == "op-abc" && ops[0].EnqueuedTime == enqueuedTime), + Arg.Any?>(), + Arg.Any()); + } + + private void SetupAppInsightsResponse(string operationId) + { + var results = new Dictionary { [operationId] = new() { OperationId = operationId } }; + + _mockAppInsights.DiagnoseBatchAsync(Arg.Any>(), + Arg.Any?>(), + Arg.Any()) + .Returns(results); + } +} diff --git a/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandlerShould.cs b/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandlerShould.cs index 288b77a..cc2f0ac 100644 --- a/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandlerShould.cs +++ b/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseDlqCommandHandlerShould.cs @@ -398,6 +398,91 @@ await _mockAppInsights.Received(1).DiagnoseBatchAsync(Arg.Is()); } + [Fact] + public async Task NotInitializeAppInsightsService_WhenAppInsightsResourceIdIsNull() + { + // Arrange + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithDiagnosticId("abc123def456abc123def456abc12345") + .WithSequenceNumber(1) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateBasicCommand(); + + // Act + await _handler.Handle(command, TestContext.Current.CancellationToken); + + // Assert + _mockAppInsights.DidNotReceive().Initialize(Arg.Any()); + } + + [Fact] + public async Task ReturnOneResultPerMessage_WhenAppInsightsResourceIdIsNull() + { + // Arrange + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithSequenceNumber(1) + .Build(), + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-2") + .WithSequenceNumber(2) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateBasicCommand(); + + // Act + var result = await _handler.Handle(command, TestContext.Current.CancellationToken); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.Results.Count.ShouldBe(2); + result.Value.SkippedNoOperationId.ShouldBe(0); + result.Value.TotalProcessed.ShouldBe(2); + } + + [Fact] + public async Task PopulateDeadLetterReason_WhenAppInsightsResourceIdIsNull() + { + // Arrange + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithDeadLetterReason("MaxDeliveryCountExceeded") + .WithSubject("OrderProcessor") + .WithSequenceNumber(1) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateBasicCommand(); + + // Act + var result = await _handler.Handle(command, TestContext.Current.CancellationToken); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.Results.Count.ShouldBe(1); + + var diagnostic = result.Value.Results.First(); + diagnostic.MessageId.ShouldBe("msg-1"); + diagnostic.DeadLetterReason.ShouldBe("MaxDeliveryCountExceeded"); + diagnostic.Subject.ShouldBe("OrderProcessor"); + } + private DiagnoseDlqCommand CreateCommand( DateTimeOffset? beforeTime = null, IReadOnlySet? categoryFilter = null) => @@ -408,6 +493,12 @@ private DiagnoseDlqCommand CreateCommand( beforeTime, categoryFilter); + private DiagnoseDlqCommand CreateBasicCommand() => + new("test.servicebus.windows.net", + EntityTargetBuilder.Queue(), + null, + 100); + private void SetupAppInsightsResponse(string operationId) { var results = new Dictionary { [operationId] = new() { OperationId = operationId } }; diff --git a/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseFromCacheCommandHandlerShould.cs b/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseFromCacheCommandHandlerShould.cs index 3ed2157..bf53488 100644 --- a/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseFromCacheCommandHandlerShould.cs +++ b/tests/ServiceBusToolset.Application.Tests/DeadLetters/DiagnoseDlq/DiagnoseFromCacheCommandHandlerShould.cs @@ -100,6 +100,57 @@ public async Task InitializeAppInsights_WhenHandled() _mockAppInsights.Received(1).Initialize("test-resource"); } + [Fact] + public async Task NotInitializeAppInsightsService_WhenAppInsightsResourceIdIsNull() + { + // Arrange + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithDiagnosticId("abc123def456abc123def456abc12345") + .WithSequenceNumber(1) + .Build() + }; + + var command = new DiagnoseFromCacheCommand(null, messages); + + // Act + await _handler.Handle(command, TestContext.Current.CancellationToken); + + // Assert + _mockAppInsights.DidNotReceive().Initialize(Arg.Any()); + } + + [Fact] + public async Task ReturnResultForEveryMessage_WhenAppInsightsResourceIdIsNull() + { + // Arrange + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-with-opid") + .WithDiagnosticId("abc123def456abc123def456abc12345") + .WithSequenceNumber(1) + .Build(), + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-without-opid") + .WithSequenceNumber(2) + .Build() + }; + + var command = new DiagnoseFromCacheCommand(null, messages); + + // Act + var result = await _handler.Handle(command, TestContext.Current.CancellationToken); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.Results.Count.ShouldBe(2); + result.Value.SkippedNoOperationId.ShouldBe(0); + result.Value.TotalProcessed.ShouldBe(2); + } + private void SetupAppInsightsResponse(string operationId) { var results = new Dictionary { [operationId] = new() { OperationId = operationId } }; diff --git a/tests/ServiceBusToolset.Application.Tests/DeadLetters/PeekDlq/PeekDlqBatchCommandHandlerShould.cs b/tests/ServiceBusToolset.Application.Tests/DeadLetters/PeekDlq/PeekDlqBatchCommandHandlerShould.cs new file mode 100644 index 0000000..75173c3 --- /dev/null +++ b/tests/ServiceBusToolset.Application.Tests/DeadLetters/PeekDlq/PeekDlqBatchCommandHandlerShould.cs @@ -0,0 +1,261 @@ +using ServiceBusToolset.Application.DeadLetters.PeekDlq; +using ServiceBusToolset.Application.Tests.Common.Builders; +using ServiceBusToolset.Application.Tests.Common.Mocks; +using Shouldly; +using Xunit; + +namespace ServiceBusToolset.Application.Tests.DeadLetters.PeekDlq; + +public class PeekDlqBatchCommandHandlerShould +{ + private readonly MockServiceBusClientFactory _mockFactory; + private readonly PeekDlqBatchCommandHandler _handler; + + public PeekDlqBatchCommandHandlerShould() + { + _mockFactory = MockServiceBusClientFactory.Create(); + _handler = new PeekDlqBatchCommandHandler(_mockFactory.Object); + } + + [Fact] + public async Task ReturnEmptyResult_WhenNoMessages() + { + _mockFactory.WithNoMessages(); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Messages.ShouldBeEmpty(); + result.Value.PeekedInBatch.ShouldBe(0); + result.Value.HasMoreMessages.ShouldBeFalse(); + } + + [Fact] + public async Task ExtractOperationId_WhenDiagnosticIdIsProvided() + { + var traceId = "abc123def456abc123def456abc12345"; + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithSubject("OrderCreated") + .WithDeadLetterReason("MaxDeliveryCountExceeded") + .WithDiagnosticId(traceId) + .WithSequenceNumber(1) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Messages.Count.ShouldBe(1); + result.Value.Messages[0].OperationId.ShouldBe(traceId); + result.Value.Messages[0].MessageId.ShouldBe("msg-1"); + result.Value.Messages[0].Subject.ShouldBe("OrderCreated"); + result.Value.Messages[0].DeadLetterReason.ShouldBe("MaxDeliveryCountExceeded"); + } + + [Fact] + public async Task SkipMessages_WhenNoOperationId() + { + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-no-opid") + .WithSequenceNumber(1) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Messages.ShouldBeEmpty(); + result.Value.PeekedInBatch.ShouldBe(1); + result.Value.SkippedNoOperationId.ShouldBe(1); + } + + [Fact] + public async Task DeduplicateOperationIds_WhenDuplicatesExist() + { + var sameTraceId = "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"; + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithDiagnosticId(sameTraceId) + .WithSequenceNumber(1) + .Build(), + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-2") + .WithDiagnosticId(sameTraceId) + .WithSequenceNumber(2) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Messages.Count.ShouldBe(1); + result.Value.PeekedInBatch.ShouldBe(2); + } + + [Fact] + public async Task ReturnLastSequenceNumber_WhenBatchCompleted() + { + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithDiagnosticId("abc123def456abc123def456abc12345") + .WithSequenceNumber(42) + .Build(), + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-2") + .WithDiagnosticId("def456abc123def456abc123def45678") + .WithSequenceNumber(99) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.LastSequenceNumber.ShouldBe(99); + } + + [Fact] + public async Task PreserveEnqueuedTime_WhenMessagesAreEnqueued() + { + var traceId = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; + var enqueuedTime = new DateTimeOffset(2026, + 3, + 20, + 8, + 0, + 0, + TimeSpan.Zero); + + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithDiagnosticId(traceId) + .WithEnqueuedTime(enqueuedTime) + .WithSequenceNumber(1) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Messages[0].EnqueuedTime.ShouldBe(enqueuedTime); + } + + [Fact] + public async Task SetHasMoreMessages_WhenBatchIsFull() + { + // Create exactly BatchSize messages — handler should report HasMoreMessages=true + var batchSize = 5; + var messages = Enumerable.Range(1, batchSize) + .Select(i => ServiceBusReceivedMessageBuilder.Create() + .WithMessageId($"msg-{i}") + .WithDiagnosticId($"{i:D32}") + .WithSequenceNumber(i) + .Build()) + .ToArray(); + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(batchSize); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.PeekedInBatch.ShouldBe(batchSize); + result.Value.HasMoreMessages.ShouldBeTrue(); + } + + [Fact] + public async Task SetHasMoreMessagesFalse_WhenFewerThanBatchSize() + { + // Create fewer messages than BatchSize — handler should report HasMoreMessages=false + var messages = new[] + { + ServiceBusReceivedMessageBuilder.Create() + .WithMessageId("msg-1") + .WithDiagnosticId("abc123def456abc123def456abc12345") + .WithSequenceNumber(1) + .Build() + }; + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.PeekedInBatch.ShouldBe(1); + result.Value.HasMoreMessages.ShouldBeFalse(); + } + + [Fact] + public async Task FillBatchAcrossMultipleSubBatches_WhenSubBatchesAreRequired() + { + // Create more messages than a single sub-batch (100) but within BatchSize + // The mock returns messages paginated by maxMessages per call, + // so the handler must loop to collect them all + var messageCount = 250; + var messages = Enumerable.Range(1, messageCount) + .Select(i => ServiceBusReceivedMessageBuilder.Create() + .WithMessageId($"msg-{i}") + .WithDiagnosticId($"{i:D32}") + .WithSequenceNumber(i) + .Build()) + .ToArray(); + + _mockFactory.WithMessagesToReturn(messages); + + var command = CreateCommand(); + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.PeekedInBatch.ShouldBe(messageCount); + result.Value.Messages.Count.ShouldBe(messageCount); + result.Value.HasMoreMessages.ShouldBeFalse(); + } + + [Fact] + public async Task UseKnownDeadLetterCount_WhenProvided() + { + _mockFactory.WithNoMessages(); + + var command = new PeekDlqBatchCommand("test.servicebus.windows.net", + EntityTargetBuilder.Queue(), + KnownDeadLetterCount:42); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.TotalDeadLetterCount.ShouldBe(42); + } + + private static PeekDlqBatchCommand CreateCommand(int batchSize = 500, long? fromSequenceNumber = null) => + new("test.servicebus.windows.net", + EntityTargetBuilder.Queue(), + batchSize, + fromSequenceNumber); +} diff --git a/tests/ServiceBusToolset.Integration.Tests/DeadLetters/DiagnoseBatchIntegrationShould.cs b/tests/ServiceBusToolset.Integration.Tests/DeadLetters/DiagnoseBatchIntegrationShould.cs new file mode 100644 index 0000000..6ff2f44 --- /dev/null +++ b/tests/ServiceBusToolset.Integration.Tests/DeadLetters/DiagnoseBatchIntegrationShould.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights; +using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models; +using ServiceBusToolset.Integration.Tests.Infrastructure; +using Shouldly; +using Xunit; + +namespace ServiceBusToolset.Integration.Tests.DeadLetters; + +public class DiagnoseBatchIntegrationShould : BaseIntegrationTest +{ + private readonly IAppInsightsService _mockAppInsights; + + public DiagnoseBatchIntegrationShould(ServiceBusEmulatorFixture fixture) + : base(fixture, ConfigureMock(out var mock)) + { + _mockAppInsights = mock; + } + + private static Action ConfigureMock(out IAppInsightsService mock) + { + var m = Substitute.For(); + mock = m; + return services => + { + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IAppInsightsService)); + if (descriptor != null) + { + services.Remove(descriptor); + } + + services.AddSingleton(m); + }; + } + + [Fact] + public async Task ReturnDiagnosticResults_ForOperationBatch() + { + var operationId = "abc123def456abc123def456abc12345"; + var enqueuedTime = DateTimeOffset.UtcNow; + + _mockAppInsights.DiagnoseBatchAsync(Arg.Any>(), + Arg.Any?>(), + Arg.Any()) + .Returns(callInfo => + { + var ops = callInfo.ArgAt>(0); + var results = new Dictionary(); + foreach (var (opId, _) in ops) + { + results[opId] = new DiagnosticResult + { + OperationId = opId, + Exceptions = + [ + new ExceptionInfo + { + ExceptionType = "TestException", + OuterMessage = "Test error" + } + ] + }; + } + + return results; + }); + + var operations = new List + { + new(operationId, + enqueuedTime, + "msg-1", + "OrderCreated", + "MaxDeliveryCountExceeded") + }; + + var sender = CreateSender(); + + var result = await sender.Send(new DiagnoseBatchCommand("test-resource", operations), + TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.Count.ShouldBe(1); + result.Value[0].MessageId.ShouldBe("msg-1"); + result.Value[0].Subject.ShouldBe("OrderCreated"); + result.Value[0].DeadLetterReason.ShouldBe("MaxDeliveryCountExceeded"); + result.Value[0].Exceptions.Count.ShouldBe(1); + } + + [Fact] + public async Task ReturnEmptyResults_WhenNoOperations() + { + var sender = CreateSender(); + + var result = await sender.Send(new DiagnoseBatchCommand("test-resource", []), + TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } +} diff --git a/tests/ServiceBusToolset.Integration.Tests/DeadLetters/PeekDlqBatchIntegrationShould.cs b/tests/ServiceBusToolset.Integration.Tests/DeadLetters/PeekDlqBatchIntegrationShould.cs new file mode 100644 index 0000000..4a7a785 --- /dev/null +++ b/tests/ServiceBusToolset.Integration.Tests/DeadLetters/PeekDlqBatchIntegrationShould.cs @@ -0,0 +1,131 @@ +using Azure.Messaging.ServiceBus; +using ServiceBusToolset.Application.DeadLetters.PeekDlq; +using ServiceBusToolset.Integration.Tests.Infrastructure; +using Shouldly; +using Xunit; +using EntityTarget = ServiceBusToolset.Application.Common.ServiceBus.Models.EntityTarget; + +namespace ServiceBusToolset.Integration.Tests.DeadLetters; + +public class PeekDlqBatchIntegrationShould : BaseIntegrationTest +{ + public PeekDlqBatchIntegrationShould(ServiceBusEmulatorFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task ReturnPeekedMessages_WithExtractedOperationIds() + { + var queue = GetQueue("peek-batch-with-id"); + await CreateQueueAsync(queue); + + var target = EntityTarget.ForQueue(queue); + var msg = new ServiceBusMessage("test-body") + { + Subject = "Order.Failed", + ApplicationProperties = { ["Diagnostic-Id"] = "00-abc123def456-0123456789ab-01" } + }; + + await DeadLetterMessageAsync(target, msg, "ProcessingFailed"); + + var sender = CreateSender(); + + var result = await sender.Send(new PeekDlqBatchCommand("ignored-by-emulator", target, 100), + TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.PeekedInBatch.ShouldBe(1); + result.Value.SkippedNoOperationId.ShouldBe(0); + result.Value.Messages.Count.ShouldBe(1); + result.Value.Messages[0].OperationId.ShouldBe("abc123def456"); + result.Value.Messages[0].Subject.ShouldBe("Order.Failed"); + result.Value.HasMoreMessages.ShouldBeFalse(); + result.Value.LastSequenceNumber.ShouldNotBeNull(); + } + + [Fact] + public async Task ReturnEmptyResult_WhenDlqIsEmpty() + { + var queue = GetQueue("peek-batch-empty"); + await CreateQueueAsync(queue); + + var target = EntityTarget.ForQueue(queue); + var sender = CreateSender(); + + var result = await sender.Send(new PeekDlqBatchCommand("ignored-by-emulator", target, 100), + TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.PeekedInBatch.ShouldBe(0); + result.Value.Messages.ShouldBeEmpty(); + result.Value.HasMoreMessages.ShouldBeFalse(); + } + + [Fact] + public async Task PaginateThroughMessages_UsingSequenceNumber() + { + var queue = GetQueue("peek-batch-paginate"); + await CreateQueueAsync(queue); + + var target = EntityTarget.ForQueue(queue); + + // Send 3 messages to DLQ + for (var i = 0; i < 3; i++) + { + var msg = new ServiceBusMessage($"body-{i}") + { + Subject = $"Event.{i}", + ApplicationProperties = { ["Diagnostic-Id"] = $"00-trace{i:D32}-span-01" } + }; + await DeadLetterMessageAsync(target, msg, $"Reason{i}"); + } + + var sender = CreateSender(); + + // First batch: get 2 messages + var result1 = await sender.Send(new PeekDlqBatchCommand("ignored-by-emulator", target, 2), + TestContext.Current.CancellationToken); + + result1.IsSuccess.ShouldBeTrue(); + result1.Value.PeekedInBatch.ShouldBe(2); + result1.Value.HasMoreMessages.ShouldBeTrue(); + result1.Value.LastSequenceNumber.ShouldNotBeNull(); + + // Second batch: resume from last sequence number + var result2 = await sender.Send(new PeekDlqBatchCommand("ignored-by-emulator", + target, + 2, + result1.Value.LastSequenceNumber), + TestContext.Current.CancellationToken); + + result2.IsSuccess.ShouldBeTrue(); + result2.Value.PeekedInBatch.ShouldBe(1); + result2.Value.HasMoreMessages.ShouldBeFalse(); + } + + [Fact] + public async Task ReturnTotalDeadLetterCount_WhenDlqHasMessages() + { + var queue = GetQueue("peek-batch-count"); + await CreateQueueAsync(queue); + + var target = EntityTarget.ForQueue(queue); + + for (var i = 0; i < 3; i++) + { + var msg = new ServiceBusMessage($"body-{i}") { ApplicationProperties = { ["Diagnostic-Id"] = $"00-count{i:D32}-span-01" } }; + await DeadLetterMessageAsync(target, msg); + } + + await WaitForDlqCountAsync(target, 3); + + var sender = CreateSender(); + + var result = await sender.Send(new PeekDlqBatchCommand("ignored-by-emulator", target, 100), + TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.TotalDeadLetterCount.ShouldBe(3); + } +} diff --git a/tests/ServiceBusToolset.IntegrationTesting/BaseServiceBusIntegrationTest.cs b/tests/ServiceBusToolset.IntegrationTesting/BaseServiceBusIntegrationTest.cs index b41f1f6..0a3cd44 100644 --- a/tests/ServiceBusToolset.IntegrationTesting/BaseServiceBusIntegrationTest.cs +++ b/tests/ServiceBusToolset.IntegrationTesting/BaseServiceBusIntegrationTest.cs @@ -37,7 +37,7 @@ protected BaseServiceBusIntegrationTest( var services = new ServiceCollection(); - services.AddApplication(); + services.AddServiceBusToolsetApplication(); services.AddSingleton(new EmulatorServiceBusClientFactory(fixture.ConnectionString, fixture.AdministrationConnectionString));