diff --git a/audit/README.md b/audit/README.md new file mode 100644 index 00000000..6674c6b0 --- /dev/null +++ b/audit/README.md @@ -0,0 +1,13 @@ +# Pulse Audit + +Multi-agent quality + practical-usability audit. + +## Loop schema +- **Phase 1 (looped)** — Discovery scouts (1 quality + 1 usability) in worktrees form ASSUMPTIONS only. Output → `assumptions/round-NN-quality.md` and `assumptions/round-NN-usability.md`. Each round must read prior rounds and avoid duplicating earlier IDs; new assumptions in new round get a fresh prefix (`Q01b`, `U01b`, …) or new numbers continuing the series. +- **Phase 2** — Verifiers in worktrees independently confirm/refute each assumption with file:line evidence, then write FAILING tests for every confirmed one. Output → `verification/round-NN-.md` + tests under `audit/tests/` (later promoted into the real test projects per provider). +- **Phase 3** — Builders in worktrees, 1 PR per confirmed assumption, English (US), conventional commits. + +## Conventions +- All agents work in `git worktree`-isolated copies. +- All PRs against `main`, English (US), conventional commits. +- No code change without a confirming verifier and a failing test first. diff --git a/audit/assumptions/round-01-quality.md b/audit/assumptions/round-01-quality.md new file mode 100644 index 00000000..f59144ee --- /dev/null +++ b/audit/assumptions/round-01-quality.md @@ -0,0 +1,80 @@ +# Phase 1 — Round 01 — Quality Discovery + +> Read-only assumptions. Each must be confirmed or refuted in Phase 2 with file:line evidence. + +## Repo Snapshot +19 src projects: core (`NetEvolve.Pulse`), `Extensibility`, source generator, AspNetCore, EF Core, 4 ADO.NET providers (SqlServer, PostgreSql, SQLite, plus EF-backed MySql/CosmosDb/MongoDB), 3 native transports (Kafka, RabbitMQ, AzureServiceBus), 3 cross-cutting (Polly, FluentValidation, HttpCorrelation), Dapr, Redis, MySql. Core abstractions: `IMediator` (`PulseMediator` in `Internals/`), open-generic interceptors per request kind, `IEventDispatcher` strategies (Parallel/Sequential/Prioritized/RateLimited), outbox subsystem split into `IEventOutbox` + `IOutboxRepository` + `IMessageTransport` + `OutboxProcessorHostedService`. Outbox state machine driven by stored procedures/functions (`get_pending_outbox_messages` flips Pending→Processing using FOR UPDATE SKIP LOCKED), batch defaults `EnableBatchSending=false`, `EnableExponentialBackoff=false`, `MaxRetryCount=3`. TimeProvider is wired through most code; outbox processor leaks back to `DateTimeOffset.UtcNow` in two spots. + +## Assumptions + +### Q01 — Singleton hosted service consumes scoped IOutboxRepository (captive dependency) +- Claim: `OutboxProcessorHostedService` is registered Singleton but constructor-injects `IOutboxRepository`, which is registered Scoped by SQL Server/PostgreSql/SQLite/EF extensions. No `IServiceScopeFactory.CreateScope()` is created per polling cycle. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:93-119` (constructor takes `IOutboxRepository`), `src/NetEvolve.Pulse/OutboxExtensions.cs:72-73` (`Singleton`), `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:199-202` (`AddScoped`), `src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxRepository.cs:41-47` (takes `TContext`). +- Why it matters: Captured EF `DbContext` is not thread-safe, accumulates change-tracker state forever, prevents per-poll connection reuse. ADO.NET impls mask this (fresh connections per call); EF-backed users will hit `ObjectDisposedException`/concurrency exceptions or unbounded memory growth. +- Test idea: Register `AddEntityFrameworkOutbox()`, build host with `ValidateScopes=true`, assert startup throws `InvalidOperationException` for the singleton-consuming-scoped registration; or assert same `IOutboxRepository`/`DbContext` instance across two simulated polling cycles. + +### Q02 — Stuck "Processing" rows never recovered (broken at-least-once) +- Claim: Outbox processor flips messages to `Status=1` (Processing) in the fetch query, but has no reaper for rows stuck in Processing if the host dies between fetch and `MarkAsCompleted/Failed/DeadLetter`. Pending-fetch and retry-fetch only look at `Status=0` and `Status=3`. +- Evidence: `src/NetEvolve.Pulse.PostgreSql/Scripts/OutboxMessage.sql:78-105` (UPDATE…SET Status=1 inside `get_pending_outbox_messages`), `:131-162`, `:180-181`, `:202-203`; `src/NetEvolve.Pulse.SqlServer/Outbox/SqlServerOutboxRepository.cs:163-199`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:236-292` (no reaper). +- Why it matters: Breaks at-least-once guarantee on crash/OOM/SIGKILL. Rows stay Processing forever; `pulse.outbox.pending` metric will not see them. +- Test idea: Insert one Pending row, call `GetPendingAsync`, simulate crash (skip MarkAsCompleted), restart host, assert second `GetPendingAsync` returns the stuck row (currently does not). + +### Q03 — OutboxProcessorHostedService bypasses TimeProvider +- Claim: Uses `DateTimeOffset.UtcNow` directly instead of injected `TimeProvider` (which the rest of the codebase honors: `IdempotencyStore`, `OutboxEventStore`, `ActivityAndMetricsRequestInterceptor`). +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:384` (`_options.ComputeNextRetryAt(DateTimeOffset.UtcNow, …)`), `:477` (`var now = DateTimeOffset.UtcNow;`). Compare `src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs:52` using `_timeProvider.GetUtcNow()`. +- Why it matters: Backoff schedules computed with a fake `TimeProvider` are not honored; deterministic retry-window assertions impossible; persisted `NextRetryAt` will not match the test clock. +- Test idea: Inject `FakeTimeProvider` fixed at 2030-01-01, force one message to fail with `EnableExponentialBackoff=true`, assert persisted `NextRetryAt = 2030-01-01 + BaseRetryDelay`. + +### Q04 — IdempotencyCommandInterceptor is check-then-act, not atomic +- Claim: `ExistsAsync` → handler → `StoreAsync`. Two concurrent commands with the same key both pass `ExistsAsync` before either calls `StoreAsync` — handler executes twice. +- Evidence: `src/NetEvolve.Pulse/Interceptors/IdempotencyCommandInterceptor.cs:68-79`; `src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs:47-64` (no atomic put-if-absent in contract). +- Why it matters: Claims at-most-once execution but only catches sequential duplicates. +- Test idea: Two `IIdempotentCommand`s, same key, gated handler, `Task.WhenAll`; assert handler ran once and the second call surfaced `IdempotencyConflictException`. + +### Q05 — ConcurrentCommandGuardInterceptor: dispose race + GetOrAdd factory leak +- Claim: Singleton interceptor with `Dispose()` walking `_semaphores`. Mid-shutdown awaits surface `ObjectDisposedException` instead of `OperationCanceledException`. `GetOrAdd(typeof(TRequest), _ => new SemaphoreSlim(1,1))` can leak losing-race semaphores. +- Evidence: `src/NetEvolve.Pulse/Interceptors/ConcurrentCommandGuardInterceptor.cs:45,49-68,71-84`. +- Why it matters: ODE during shutdown masks real cancellation; rare contention leaks under heavy load. +- Test idea: `Parallel.For` to provoke factory races and reflectively assert all created semaphores are present; await semaphore in HandleAsync, dispose on another thread, assert surfaced exception is `OperationCanceledException`. + +### Q06 — KafkaMessageTransport ignores CancellationToken on Flush + IsHealthy +- Claim: `_producer.Flush(Timeout.InfiniteTimeSpan)` blocks indefinitely and ignores the passed token; `IsHealthyAsync` does sync `_adminClient.GetMetadata(TimeSpan.FromSeconds(5))` wrapped in `Task.FromResult`. +- Evidence: `src/NetEvolve.Pulse.Kafka/Outbox/KafkaMessageTransport.cs:94`, `:63-102`, `:105-116`. +- Why it matters: `IHostApplicationLifetime` cannot interrupt a stuck flush — graceful shutdown becomes ungraceful, increasing stuck-Processing risk (Q02). +- Test idea: Fake `IProducer<…>` whose `Flush` blocks forever, `SendBatchAsync` with token canceling after 100 ms; assert the task completes (it will not). + +### Q07 — Azure Service Bus non-atomic batch when EnableBatching=false +- Claim: `SendBatchAsync` loops `sender.SendMessageAsync` per message when batching disabled — non-atomic. `ProcessBatchSendAsync` then marks entire batch failed on any error → re-send next poll → duplicate delivery. +- Evidence: `src/NetEvolve.Pulse.AzureServiceBus/Outbox/AzureServiceBusMessageTransport.cs:55-89`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:456-500`. +- Why it matters: Violates `IMessageTransport.SendBatchAsync` atomicity contract used by the processor. +- Test idea: Fake `ServiceBusSender` succeeds for messages 1+2 then throws on 3, run with `EnableBatching=false`; verify broker received first two, outbox marks all three Failed (next poll → duplicates). + +### Q08 — RabbitMqMessageTransport channel leak on reconnect +- Claim: `EnsureChannelAsync` overwrites stale `_channel` without disposing the previous instance — leaks an `IRabbitMqChannelAdapter` and its AMQP channel per reconnect. +- Evidence: `src/NetEvolve.Pulse.RabbitMQ/Outbox/RabbitMqMessageTransport.cs:124-147`. +- Why it matters: Long-running services exhaust the connection's `channel_max` budget on transient disconnects → `ChannelAllocationException`. +- Test idea: Fake adapter that returns distinct disposable instances and flips `IsOpen=false` after each publish; send 100 messages, assert previous adapters were disposed. + +### Q09 — Batch mark operations are not atomic (per-row + parallel) +- Claim: Default-interface batch methods on `IOutboxRepository` iterate via `Parallel.ForEachAsync` to single-item overloads (fresh connections, no transaction). PostgreSQL override iterates sequentially under one connection but no transaction. A transient mid-loop failure leaves some Completed, others Processing → duplicates next poll. +- Evidence: `src/NetEvolve.Pulse.Extensibility/Outbox/IOutboxRepository.cs:59-69,113-124,142-153`; `src/NetEvolve.Pulse.PostgreSql/Outbox/PostgreSqlOutboxRepository.cs:243-265`; `src/NetEvolve.Pulse.SqlServer/Outbox/SqlServerOutboxRepository.cs:273-284`. +- Why it matters: `ProcessBatchSendAsync` (Outbox/OutboxProcessorHostedService.cs:438-439) needs atomic `MarkAsCompletedAsync(ids[])` for at-least-once. +- Test idea: Flaky connection failing after N successes; call `MarkAsCompletedAsync({id1,id2,id3})` failing after id1; assert all-or-none semantics (current: only id1 Completed). + +### Q10 — Timeout-cancel vs external-cancel confusion in ProcessMessageAsync +- Claim: When `timeoutCts.CancelAfter` fires, `OperationCanceledException` does not satisfy `when (cancellationToken.IsCancellationRequested)` filter — falls into generic catch and gets treated as retryable failure with msg "The operation was canceled." Telemetry status remains `Ok` while DB state is Failed/DeadLetter. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:331-401`; `src/NetEvolve.Pulse/Interceptors/ActivityAndMetricsRequestInterceptor.cs:105-143`. +- Why it matters: "Processed-but-failed" rows with success logs → operator confusion. +- Test idea: `ProcessingTimeout=50ms`, transport sleeps 200ms; assert row Status=Failed, RetryCount=1, `pulse.outbox.failed.total` advanced, and `ExecuteAsync` did not fault. + +### Q11 — Race in batch failure classification reads EventTypeOverrides three times +- Claim: `ProcessBatchSendAsync` invokes `_options.GetEffectiveMaxRetryCount(m.EventType)` independently in three LINQ predicates — `EventTypeOverrides` is a public `ConcurrentDictionary` and can mutate between reads, causing dead-letter log lines to disagree with mark-as-failed DB writes. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:465-518`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:118-119`. +- Why it matters: Inconsistent telemetry vs DB state when overrides tuned at runtime. +- Test idea: Custom repo whose `MarkAsFailedAsync(ids[])` delays 50ms; another thread mutates `EventTypeOverrides[typeof(MyEvent)].MaxRetryCount=1`; assert dead-letter log count ≠ DB dead-letter row count. + +### Q12 (lower confidence) — Interceptor pipeline ordering is implicit +- Claim: Pipeline ordering is registration-order driven via `Reverse().ToArray()` + wrap-from-innermost-out. No priority hook; users cannot predict whether timeout wraps idempotency or vice versa without inspecting registration order. +- Evidence: `src/NetEvolve.Pulse/Internals/PulseMediator.cs:232,243-249`; `src/NetEvolve.Pulse/IdempotencyExtensions.cs:61-63`; `src/NetEvolve.Pulse/ConcurrentCommandGuardExtensions.cs:50-52`. +- Why it matters: Determines whether timeout fires inside or outside concurrency guard, and whether idempotency conflicts are caught by Polly retry. Fragile, load-bearing on registration order. +- Test idea: Register `AddTimeout()` then `AddIdempotency()` and verify which exception escapes; swap order and observe change. diff --git a/audit/assumptions/round-01-usability.md b/audit/assumptions/round-01-usability.md new file mode 100644 index 00000000..5b7da724 --- /dev/null +++ b/audit/assumptions/round-01-usability.md @@ -0,0 +1,98 @@ +# Phase 1 — Round 01 — Usability Discovery + +> Read-only assumptions. Each must be confirmed or refuted in Phase 2 with file:line evidence. + +## Repo Snapshot +~19 src projects (core, source-gen, ASP.NET Core, FluentValidation, HttpCorrelation, Polly, persistence + transport providers). Multi-targets `net8.0;net9.0;net10.0`. Heavy XML docs and per-project READMEs. C# 14 `extension(...)` member syntax pervasively. Central versioning via `Directory.Packages.props`. NuGet metadata per-csproj. No `PackageIcon`/`PackageReadmeFile` anywhere. `SystemTextJsonPayloadSerializer` default and unconditional. + +## Assumptions + +### U01 — README "Quick Use" snippet does not compile / does not dispatch +- Claim: Root README snippet declares record/handler types at file scope after top-level statements and never builds the provider or dispatches anything. Compile error guaranteed. +- Evidence: `README.md:118-136`; counter-example `src/NetEvolve.Pulse/README.md:42-72`. +- Why it matters: First-impression sample. Copy-paste = immediate compile failure. +- Test idea: Paste the README:118-136 snippet verbatim into a new console project; build on net8/net9/net10. + +### U02 — README mis-describes QueryCachingOptions API +- Claim: README claims `QueryCachingOptions` exposes `JsonSerializerOptions`; the type actually only has `ExpirationMode` and `DefaultExpiry`. Serialization is controlled via `IPayloadSerializer` and `IOptions` globally. +- Evidence: `README.md:59`; `src/NetEvolve.Pulse/README.md:152-162`; type at `src/NetEvolve.Pulse.Extensibility/Caching/QueryCachingOptions.cs:10-59`. +- Why it matters: Doc'd code does not compile. +- Test idea: Compile `src/NetEvolve.Pulse/README.md:155-162` snippet → CS0117. + +### U03 — XML doc references non-existent NuGet package +- Claim: `AssemblyScanningExtensions` XML doc recommends `NetEvolve.Pulse.Generators`; actual package is `NetEvolve.Pulse.SourceGeneration`. Typo-squat risk on NuGet. +- Evidence: `src/NetEvolve.Pulse/AssemblyScanningExtensions.cs:15`. +- Why it matters: Users `dotnet add package` something that doesn't exist. +- Test idea: Verify package name vs csproj ``. + +### U04 — Default JSON serializer breaks AOT silently +- Claim: `SystemTextJsonPayloadSerializer` uses reflection-mode `JsonSerializer.Serialize` / `Deserialize` with no `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` annotations. Conflicts with the library's AOT-friendly positioning (`HandlerRegistrationExtensions` advertises AOT compatibility). +- Evidence: `src/NetEvolve.Pulse/Serialization/SystemTextJsonPayloadSerializer.cs:32-44`; AOT claim `src/NetEvolve.Pulse/HandlerRegistrationExtensions.cs:9,15-17`. +- Why it matters: AOT users get runtime failures or silent metadata loss without compile-time warnings. +- Test idea: AOT publish a console app using `AddPulse() + AddQueryCaching()`; verify no IL2026/IL3050 warnings even though reflection happens. + +### U05 — Critical options classes have no IValidateOptions +- Claim: `OutboxProcessorOptions` accepts `BatchSize<=0`, `PollingInterval<=TimeSpan.Zero`, `BackoffMultiplier<=0`, `ProcessingTimeout=TimeSpan.Zero`. `AzureServiceBusTransportOptions` validation runs inline at first resolution, not at startup. Only `LoggingInterceptorOptions` has a registered validator. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:10-99`; `src/NetEvolve.Pulse.AzureServiceBus/AzureServiceBusExtensions.cs:75-86`; only validator: `src/NetEvolve.Pulse/Interceptors/LoggingInterceptorOptionsValidator.cs`. +- Why it matters: Misconfig fails deep inside processor at runtime instead of failing fast at startup. No `ValidateOnStart()` wired up. +- Test idea: Configure `BatchSize=0, PollingInterval=Zero`, build host; verify host starts cleanly. + +### U06 — Aggressive retry defaults +- Claim: `MaxRetryCount=3`, `EnableExponentialBackoff=false`, `EnableBatchSending=false`. New user calling `AddOutbox()` gets 3 quick retries with no backoff (gated only by polling interval) → dead-letter on transient failures. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:28,40,47`. +- Why it matters: Headline reliability feature configured for foot-gun. +- Test idea: Processor with no overrides + transport that fails 3× then succeeds → message dead-lettered. + +### U07 — Transport extension methods do not enforce AddOutbox +- Claim: `UseAzureServiceBusTransport`, `UseKafkaTransport`, `UseRabbitMqTransport`, `UseMessageTransport` silently replace any existing `IMessageTransport` but do NOT register `IOutboxRepository` or `OutboxProcessorHostedService`. Users following per-package READMEs may get half-wired systems. +- Evidence: `src/NetEvolve.Pulse.AzureServiceBus/README.md:32-45`; `src/NetEvolve.Pulse.AzureServiceBus/AzureServiceBusExtensions.cs:26-60`; `src/NetEvolve.Pulse.Kafka/KafkaExtensions.cs:44-59`; counter-example `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:194-204`. +- Why it matters: Missing-service exceptions at publish, or silently no-op outbox. +- Test idea: Take ASB README:32-45 quick-start verbatim, build provider, call `mediator.PublishAsync(...)`; observe DI failure or silent no-op. + +### U08 — SQL Server schema script not delivered via PackageReference +- Claim: `OutboxOptions.Schema` defaults to `"pulse"`. SQL Server README says "execute the schema script from `Scripts/OutboxMessage.sql`" — script is shipped via NuGet `content\Scripts` (legacy content mechanism that does not flow to PackageReference consumers). +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxOptions.cs:18`; `src/NetEvolve.Pulse.SqlServer/NetEvolve.Pulse.SqlServer.csproj:18-19`; `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:23-25`. +- Why it matters: First publish throws "Invalid object name 'pulse.OutboxMessage'"; documented remediation does not deliver the file. +- Test idea: Add ``, build, search consumer output for `OutboxMessage.sql` → absent. + +### U09 — MapStreamQuery uses raw JsonSerializer, ignoring IPayloadSerializer/options +- Claim: `EndpointRouteBuilderExtensions.MapStreamQuery` calls `JsonSerializer.Serialize(item)` / `SerializeToUtf8Bytes(item)` with no options — does not honor configured `JsonSerializerOptions` or `IPayloadSerializer`. +- Evidence: `src/NetEvolve.Pulse.AspNetCore/EndpointRouteBuilderExtensions.cs:226,248`. +- Why it matters: `MapQuery` and `MapStreamQuery` serialize the same DTO differently (camelCase vs PascalCase); blocks AOT for stream endpoints. +- Test idea: Configure camelCase policy; add `MapQuery` and `MapStreamQuery` for same DTO; compare response property casing. + +### U10 — KafkaMessageTransport ignores CancellationToken (also Q06) +- Claim: `_producer.Flush(Timeout.InfiniteTimeSpan)` ignores token; `SendBatchAsync` declared with `CancellationToken` but never observes it. +- Evidence: `src/NetEvolve.Pulse.Kafka/Outbox/KafkaMessageTransport.cs:94`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:432-435`. +- Why it matters: Stuck broker leaks processor thread; `ProcessingTimeout` does not bound batch sending. +- Test idea: Unreachable broker, send batch, cancel token; assert task completes within `ProcessingTimeout`. + +### U11 — Transport extensions silently overwrite previous registration +- Claim: `Use*Transport` methods linear-scan and `Remove()` any existing `IMessageTransport`. No warning if a transport is overwritten by a later call. +- Evidence: `src/NetEvolve.Pulse.AzureServiceBus/AzureServiceBusExtensions.cs:51-58`; `src/NetEvolve.Pulse.Kafka/KafkaExtensions.cs:50-57`; `src/NetEvolve.Pulse.RabbitMQ/RabbitMqExtensions.cs:53-60`; `src/NetEvolve.Pulse/OutboxExtensions.cs:94-101`. +- Why it matters: Silent last-write-wins on foundational service makes misconfig undebuggable. +- Test idea: Register both ASB and Kafka in any order; resolve `IMessageTransport`; assert exactly one is registered and no diagnostic emitted. + +### U12 — IMediator registered Scoped, BackgroundService example violates DI lifetime +- Claim: `IMediator` is Scoped, but XML doc example shows `IMediatorSendOnly` injected directly into a `BackgroundService`. With `ValidateScopes=true` (dev default), host startup fails. +- Evidence: `src/NetEvolve.Pulse/ServiceCollectionExtensions.cs:110-111`; example at `src/NetEvolve.Pulse.Extensibility/IMediatorSendOnly.cs:20-34`. +- Why it matters: Copy-paste example throws `InvalidOperationException` at startup. +- Test idea: Implement the example verbatim against `Host.CreateApplicationBuilder()`; observe scope validation failure. + +### U13 — NuGet metadata missing icon + embedded README +- Claim: No `PackageIcon` / `PackageReadmeFile` set anywhere. `Directory.Build.props` defines only `Title`, `RepositoryUrl`, `PackageProjectUrl`, `PackageReleaseNotes`, `PackageTags`. +- Evidence: `Directory.Build.props:1-12`; `logo.png` at repo root unused; grep across repo finds no `PackageIcon`/`PackageReadmeFile`. +- Why it matters: NuGet listing shows generic placeholder, no README on package page. +- Test idea: `dotnet pack`; inspect resulting `.nupkg` `.nuspec` for ``/`` elements. + +### U14 — C# 14 extension blocks lock source build to .NET 10 SDK +- Claim: `extension(TReceiver)` member blocks used across 7+ files; multi-target is `net8.0;net9.0;net10.0` but `LangVersion` is not explicitly set. +- Evidence: `src/NetEvolve.Pulse/AssemblyScanningExtensions.cs:51`; `src/NetEvolve.Pulse.SqlServer/Outbox/SqlServerOutboxOptionsExtensions.cs`; `src/NetEvolve.Pulse.PostgreSql/Outbox/PostgreSqlOutboxOptionsExtensions.cs`; et al. +- Why it matters: Source builds with .NET 8 or .NET 9 SDK fail; CI on older SDKs breaks; cryptic CS1003/CS8400 errors for contributors. +- Test idea: `global.json` pinning to .NET 8 SDK, then `dotnet build` of `Pulse.slnx`. + +### U15 — Missing handler gives generic DI exception, no scanning helper +- Claim: `services.AddPulse()` with no further calls and no `[PulseHandler]` source-gen attributes throws a generic `"no service of type ICommandHandler<...>"` at first dispatch. Compared to MediatR's `RegisterServicesFromAssemblyContaining()` one-liner, Pulse requires source-gen, manual `Add*Handler`, or AOT-incompatible `AddHandlersFromCallingAssembly()`. +- Evidence: `src/NetEvolve.Pulse/ServiceCollectionExtensions.cs:88-114`; error message inherited from `GetRequiredService`. +- Why it matters: #1 first-time error with no actionable remediation in the message. +- Test idea: `services.AddPulse(); …GetRequiredService().SendAsync(new SomeCommand())`; verify error message and that it does not mention scanning or `AddCommandHandler<>`. diff --git a/audit/repros/u08/README.md b/audit/repros/u08/README.md new file mode 100644 index 00000000..f6f5b2a4 --- /dev/null +++ b/audit/repros/u08/README.md @@ -0,0 +1,57 @@ +# U08 Repro — `OutboxMessage.sql` not delivered via PackageReference + +The SQL Server outbox README directs users to "execute the schema script from +`Scripts/OutboxMessage.sql`", but the script is packed only under the legacy +`content\Scripts\` path which does **not** flow to PackageReference consumers +(the modern SDK-style default). The README's documented remediation therefore +fails to deliver the file. + +## Run the repro + +```pwsh +pwsh ./repro.ps1 +``` + +The script: + +1. `dotnet pack`s `src/NetEvolve.Pulse.SqlServer` into a scratch folder. +2. Inspects the resulting `.nupkg` for `OutboxMessage.sql` entries. +3. Reports the discovered path(s). +4. Asserts that *at least one* of the discovered paths starts with + `contentFiles/`, `build/`, or `buildTransitive/` (= PackageReference-reachable). + +Today the only entry found is `content/Scripts/OutboxMessage.sql`, so the +assertion fails and the script exits non-zero. + +Companion TUnit test: +`tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxScriptPackagingTests.cs`. + +## Why this matters + +A consumer following the README literally: + +```xml + +``` + +builds, restores, and looks under `bin/`/`obj/`/`packages/` for the script and +finds **nothing**. The first event publish then throws +`Invalid object name 'pulse.OutboxMessage'`. The documented remediation does +not deliver the file. + +## Fix sketch (out of scope — Phase 3) + +Replace the packaging line in `src/NetEvolve.Pulse.SqlServer/NetEvolve.Pulse.SqlServer.csproj`: + +```xml + +``` + +Or ship a `build\NetEvolve.Pulse.SqlServer.targets` that copies the script as +a build event. The script is already an `EmbeddedResource`; a third option is +to expose a helper that writes it to disk via +`Assembly.GetManifestResourceStream`. diff --git a/audit/repros/u08/repro.ps1 b/audit/repros/u08/repro.ps1 new file mode 100644 index 00000000..617e9b5f --- /dev/null +++ b/audit/repros/u08/repro.ps1 @@ -0,0 +1,83 @@ +#!/usr/bin/env pwsh +# U08 repro — pack NetEvolve.Pulse.SqlServer and assert OutboxMessage.sql is +# reachable from PackageReference consumers (contentFiles/, build/, or +# buildTransitive/). Today only content/Scripts/ exists, so the assertion fails. + +$ErrorActionPreference = 'Stop' + +# Locate repo root (Pulse.slnx is the marker). +$repoRoot = (Get-Item $PSScriptRoot).FullName +while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot 'Pulse.slnx'))) { + $repoRoot = (Get-Item $repoRoot).Parent?.FullName +} +if (-not $repoRoot) { + Write-Error 'Could not locate Pulse.slnx — run this from inside the repo.' + exit 2 +} + +$csproj = Join-Path $repoRoot 'src/NetEvolve.Pulse.SqlServer/NetEvolve.Pulse.SqlServer.csproj' +if (-not (Test-Path $csproj)) { + Write-Error "Csproj not found: $csproj" + exit 2 +} + +$outputDir = Join-Path ([System.IO.Path]::GetTempPath()) ("pulse-u08-" + [System.Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $outputDir -Force | Out-Null + +try { + Write-Host "Packing $csproj -> $outputDir" + & dotnet pack $csproj -o $outputDir --nologo | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet pack failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + + $nupkg = Get-ChildItem $outputDir -Filter 'NetEvolve.Pulse.SqlServer.*.nupkg' | Select-Object -First 1 + if (-not $nupkg) { + Write-Error "No nupkg produced under $outputDir" + exit 2 + } + + Write-Host "Inspecting $($nupkg.FullName)" + + Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue + $archive = [System.IO.Compression.ZipFile]::OpenRead($nupkg.FullName) + try { + $entries = $archive.Entries | ForEach-Object { $_.FullName -replace '\\', '/' } + $sqlEntries = $entries | Where-Object { $_ -like '*/OutboxMessage.sql' } + + Write-Host '' + Write-Host 'OutboxMessage.sql entries found:' + if ($sqlEntries.Count -eq 0) { + Write-Host ' (none)' + } + else { + $sqlEntries | ForEach-Object { Write-Host " - $_" } + } + Write-Host '' + + $reachable = $sqlEntries | Where-Object { + $_ -like 'contentFiles/*' -or $_ -like 'build/*' -or $_ -like 'buildTransitive/*' + } + + if ($reachable.Count -gt 0) { + Write-Host "PASS — script is reachable from PackageReference consumers:" + $reachable | ForEach-Object { Write-Host " $_" } + exit 0 + } + else { + Write-Host 'FAIL — OutboxMessage.sql is NOT reachable from PackageReference consumers.' + Write-Host ' Only content/ (legacy packages.config) paths were found.' + Write-Host ' Modern SDK-style consumers will not receive this file.' + exit 1 + } + } + finally { + $archive.Dispose() + } +} +finally { + if (Test-Path $outputDir) { + Remove-Item $outputDir -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/audit/verification/round-01-U06.md b/audit/verification/round-01-U06.md new file mode 100644 index 00000000..a3767a77 --- /dev/null +++ b/audit/verification/round-01-U06.md @@ -0,0 +1,62 @@ +# U06 Verification + +**Status:** CONFIRMED + +**Evidence:** `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:28` (`MaxRetryCount = 3`), `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:40` (`EnableBatchSending { get; set; }` — implicit `false`), `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:47` (`EnableExponentialBackoff { get; set; }` — implicit `false`). + +**Reasoning:** Re-read the type. Three options govern retry/backoff behavior on the headline reliability surface (`AddOutbox`). All three are foot-gun defaults: 3 retries (low for transient broker failures), exponential backoff disabled (retries fire back-to-back gated only by `PollingInterval`), and batching disabled (forces per-message round trips). A first-time user calling `AddOutbox()` with no overrides will dead-letter messages on any non-trivial transient outage. The failing test asserts the **desirable** defaults (backoff `true`, batching `true`, retry count `>= 5`) so Phase 3 can flip them; if Phase 3 decides to retain current defaults, the test becomes the documented-choice contract test. + +**Failing test (if confirmed):** +- Path: `tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorOptionsDefaultsTests.cs` +- Status: written + +```csharp +namespace NetEvolve.Pulse.Tests.Unit.Outbox; + +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Outbox; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U06: Asserts the *desirable* defaults for . +/// Currently FAILS — defaults are configured as a foot-gun: +/// MaxRetryCount=3, EnableExponentialBackoff=false, EnableBatchSending=false. +/// Phase 3 should either flip these to the safer values asserted below +/// or, if the current values are intentional, repurpose this test (and update its +/// assertions/message) as the documented-choice contract test. +/// +[TestGroup("Outbox")] +public sealed class OutboxProcessorOptionsDefaultsTests +{ + [Test] + public async Task Default_MaxRetryCount_should_be_at_least_5_to_survive_transient_failures() + { + var options = new OutboxProcessorOptions(); + + _ = await Assert.That(options.MaxRetryCount).IsGreaterThanOrEqualTo(5); + } + + [Test] + public async Task Default_EnableExponentialBackoff_should_be_true_to_avoid_thundering_herd() + { + var options = new OutboxProcessorOptions(); + + _ = await Assert.That(options.EnableExponentialBackoff).IsTrue(); + } + + [Test] + public async Task Default_EnableBatchSending_should_be_true_for_efficient_transport() + { + var options = new OutboxProcessorOptions(); + + _ = await Assert.That(options.EnableBatchSending).IsTrue(); + } +} +``` + +**Notes:** +- Each of the three assertions fails today (3 < 5; `false` != `true`; `false` != `true`). +- Phase 3 deliverable: either flip the defaults (changes are one-line edits in `OutboxProcessorOptions.cs:28,40,47`) or document the rationale for foot-gun defaults and rewrite this test as the contract test. +- Per-event-type override plumbing (`EventTypeOverrides`, `GetEffectiveMaxRetryCount`, `GetEffectiveEnableBatchSending`) is unaffected by any default flip — overrides remain `null` until explicitly set, so changing globals is safe. +- If Phase 3 raises `MaxRetryCount`, also reconsider `MaxRetryDelay` (currently 5 min) to ensure the upper bound is still reachable in practice. diff --git a/audit/verification/round-01-U07.md b/audit/verification/round-01-U07.md new file mode 100644 index 00000000..4a46c27a --- /dev/null +++ b/audit/verification/round-01-U07.md @@ -0,0 +1,107 @@ +# U07 Verification + +**Status:** CONFIRMED + +**Evidence:** +- `src/NetEvolve.Pulse.AzureServiceBus/README.md:32-45` (quick-start: `services.AddPulse(config => config.UseAzureServiceBusTransport(options => …))` — no `AddOutbox`, no persistence provider). +- `src/NetEvolve.Pulse.AzureServiceBus/AzureServiceBusExtensions.cs:26-60` (`UseAzureServiceBusTransport` only adds `AzureServiceBusTransportOptions`, `ServiceBusClient`, `TokenCredential`, and replaces `IMessageTransport` — does **not** call `AddOutbox()` or register `IOutboxRepository` / `OutboxProcessorHostedService`). +- `src/NetEvolve.Pulse/OutboxExtensions.cs:39-76` (`AddOutbox` is the only path that registers `OutboxProcessorHostedService` and `IMessageTransport`; `IOutboxRepository` must come from a persistence provider). +- Counter-example: `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:194-205` — `RegisterSqlServerOutboxServices` calls `AddOutbox()` and registers `IOutboxRepository` correctly. + +**Reasoning:** Following the ASB quick-start verbatim builds a service collection where (a) `AddPulse` has run, (b) `IMessageTransport` is replaced with `AzureServiceBusMessageTransport`, but (c) no `OutboxProcessorHostedService` is registered (only `AddOutbox` adds it via `TryAddEnumerable`), and (d) no `IOutboxRepository` is registered. `mediator.PublishAsync(...)` will not dispatch via the outbox at all (the `OutboxEventHandler<>` is not registered either, since that is also only set up by `AddOutbox`). The DI failure surface is a silent no-op (events queue nowhere) rather than a clear, actionable error pointing the user at the missing `AddOutbox` / `AddXxxOutbox` calls. + +**Failing test (if confirmed):** +- Path: `tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusReadmeQuickStartTests.cs` +- Status: written + +```csharp +namespace NetEvolve.Pulse.Tests.Unit.AzureServiceBus; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility.Outbox; +using NetEvolve.Pulse.Outbox; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U07: Building a service collection that follows +/// src/NetEvolve.Pulse.AzureServiceBus/README.md:32-45 verbatim must surface +/// a clear, actionable error — pointing the user at the missing AddOutbox() +/// / persistence provider call — rather than silently leaving the outbox half-wired. +/// +/// Currently FAILS: the provider builds without error, IOutboxRepository resolution +/// throws a generic "No service for type … IOutboxRepository …" message that does not +/// mention AddOutbox, AddSqlServerOutbox, or any actionable remediation. +/// +[TestGroup("AzureServiceBus")] +public sealed class AzureServiceBusReadmeQuickStartTests +{ + private const string FakeConnectionString = + "Endpoint=sb://localhost/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=Fake="; + + [Test] + public async Task AsbReadmeQuickStart_should_register_IOutboxRepository_or_throw_actionable_DI_error() + { + // ARRANGE — Verbatim transcription of README:32-45 quick-start. + var services = new ServiceCollection(); + _ = services.AddPulse(config => + config.UseAzureServiceBusTransport(options => + { + options.ConnectionString = FakeConnectionString; + options.EnableBatching = true; + }) + ); + + // ACT — Build the provider and try to resolve the persistence side. + await using var provider = services.BuildServiceProvider(); + + // ASSERT (1) — Either IOutboxRepository is registered (preferred), or + // resolving it throws an exception that *mentions* the missing call. + Exception? repositoryError = null; + try + { + _ = provider.GetRequiredService(); + } + catch (Exception ex) + { + repositoryError = ex; + } + + if (repositoryError is not null) + { + _ = await Assert + .That(repositoryError.Message) + .Contains("AddOutbox", StringComparison.OrdinalIgnoreCase) + .Or.Contains("persistence", StringComparison.OrdinalIgnoreCase) + .Or.Contains("provider", StringComparison.OrdinalIgnoreCase); + } + + // ASSERT (2) — OutboxProcessorHostedService must be wired up so the transport + // is actually drained; ASB-only registration without AddOutbox leaves it absent. + var hostedServices = provider.GetServices(); + var hasOutboxProcessor = false; + foreach (var hosted in hostedServices) + { + if (hosted is OutboxProcessorHostedService) + { + hasOutboxProcessor = true; + break; + } + } + + _ = await Assert.That(hasOutboxProcessor).IsTrue(); + } +} +``` + +**Notes:** +- The test asserts two things that *both* fail today: + 1. `IOutboxRepository` is either registered or its error message names `AddOutbox` / `persistence` / `provider`. Today the error is `"No service for type 'NetEvolve.Pulse.Extensibility.Outbox.IOutboxRepository' has been registered."` — generic, contains none of those tokens. + 2. `OutboxProcessorHostedService` is registered. Today it is not — `AddOutbox` is the only call site that adds it, and `UseAzureServiceBusTransport` does not call `AddOutbox`. +- Phase 3 fix options: (a) have every `Use*Transport` extension call `AddOutbox()` internally (mirrors `RegisterSqlServerOutboxServices`), or (b) validate at provider build / first publish that `IOutboxRepository` and `OutboxProcessorHostedService` are present, throwing an `InvalidOperationException` whose message lists the missing `Add*Outbox` calls. +- Same defect applies to `UseKafkaTransport`, `UseRabbitMqTransport`, and `UseMessageTransport` — out of scope for U07 but worth a single shared fix. diff --git a/audit/verification/round-01-U08.md b/audit/verification/round-01-U08.md new file mode 100644 index 00000000..1019aab1 --- /dev/null +++ b/audit/verification/round-01-U08.md @@ -0,0 +1,114 @@ +# U08 Verification + +**Status:** CONFIRMED + +**Evidence:** +- `src/NetEvolve.Pulse/Outbox/OutboxOptions.cs:18` (`public string Schema { get; set; } = "pulse";`). +- `src/NetEvolve.Pulse.SqlServer/NetEvolve.Pulse.SqlServer.csproj:18` — only packaging line is `` (legacy `content\` mechanism — `packages.config` only; does NOT flow to PackageReference consumers). +- `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:23-25` — XML doc: *"Execute the schema script from `Scripts/OutboxMessage.sql`"* — script not delivered. +- `src/NetEvolve.Pulse.SqlServer/Scripts/OutboxMessage.sql` — exists in the repo, but `content\Scripts` is the only `Pack=true` path. + +**Reasoning:** The `content\` folder in a NuGet package only flows to `packages.config`-style consumers. For `PackageReference`-based projects (the modern SDK-style default since .NET Core), files must live under `contentFiles\\\…` and be paired with `PackageCopyToOutput`/`BuildAction` metadata, or be delivered via `build\` / `buildTransitive\` MSBuild props/targets. Modern SDK-style consumers receive nothing from `content\`. The `EmbeddedResource Include="Scripts\*.sql"` line on `csproj:19` does embed the file into the assembly, but the README and XML docs tell users to **execute the file on disk** — there is no documented `Assembly.GetManifestResourceStream(...)` retrieval helper. + +**Failing repro:** +- Path: `audit/repros/u08/` +- Status: written + +The repro packs `NetEvolve.Pulse.SqlServer` and asserts the resulting `.nupkg` exposes `OutboxMessage.sql` at a `PackageReference`-reachable path. Today the only path used is `content\Scripts\OutboxMessage.sql`, which fails the assertion. + +A companion TUnit test (`tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxScriptPackagingTests.cs`) packs the package on demand and inspects it; this runs in CI and fails today. + +**Failing test code (TUnit, `tests/.../SqlServerOutboxScriptPackagingTests.cs`):** + +```csharp +namespace NetEvolve.Pulse.Tests.Unit.SqlServer; + +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using NetEvolve.Extensions.TUnit; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U08: OutboxMessage.sql must be reachable from PackageReference +/// consumers. Today it is packed under content\Scripts\ (legacy packages.config +/// mechanism) which does not flow to modern SDK-style consumers. +/// +[TestGroup("SqlServer")] +public sealed class SqlServerOutboxScriptPackagingTests +{ + [Test] + public async Task OutboxMessage_sql_must_be_reachable_from_PackageReference_consumers() + { + // ARRANGE — Locate the SqlServer csproj relative to repo root. + var repoRoot = LocateRepoRoot(); + var csproj = Path.Combine( + repoRoot, + "src", + "NetEvolve.Pulse.SqlServer", + "NetEvolve.Pulse.SqlServer.csproj" + ); + + _ = await Assert.That(File.Exists(csproj)).IsTrue(); + + // ACT — Pack into a scratch folder. + var outputDir = Path.Combine(Path.GetTempPath(), $"pulse-u08-{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(outputDir); + + var psi = new ProcessStartInfo("dotnet", $"pack \"{csproj}\" -o \"{outputDir}\" --nologo") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = Process.Start(psi)!; + await process.WaitForExitAsync().ConfigureAwait(false); + + _ = await Assert.That(process.ExitCode).IsEqualTo(0); + + // ASSERT — The nupkg must expose OutboxMessage.sql via contentFiles/ or + // build*/buildTransitive/ (anything that flows to PackageReference consumers). + // content\ alone does NOT — that is the legacy packages.config path. + var nupkg = Directory.EnumerateFiles(outputDir, "NetEvolve.Pulse.SqlServer.*.nupkg").First(); + + using var archive = ZipFile.OpenRead(nupkg); + var entries = archive.Entries.Select(e => e.FullName.Replace('\\', '/')).ToArray(); + + var reachableFromPackageReference = entries.Any(e => + e.EndsWith("/OutboxMessage.sql", StringComparison.OrdinalIgnoreCase) + && ( + e.StartsWith("contentFiles/", StringComparison.OrdinalIgnoreCase) + || e.StartsWith("build/", StringComparison.OrdinalIgnoreCase) + || e.StartsWith("buildTransitive/", StringComparison.OrdinalIgnoreCase) + ) + ); + + _ = await Assert.That(reachableFromPackageReference).IsTrue(); + } + + private static string LocateRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Pulse.slnx"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new InvalidOperationException("Pulse.slnx not located."); + } +} +``` + +**Notes:** +- The PowerShell repro at `audit/repros/u08/repro.ps1` is provided as a standalone, CI-independent demonstration that the SQL script is missing from PackageReference consumers. It also can run on Linux via `pwsh`. +- Phase 3 fix is small: change the csproj packaging line to one of: + - `` (with `BuildAction=None`, `CopyToOutput=true`) — copies to consumer output; + - Or ship a `build\NetEvolve.Pulse.SqlServer.targets` that copies the script as a build event; + - Or document `Assembly.GetManifestResourceStream` retrieval (the script is already embedded — line 19 of the csproj) and provide a helper method that writes it to disk on demand. +- The same defect exists for `NetEvolve.Pulse.MySql`, `NetEvolve.Pulse.PostgreSql`, and `NetEvolve.Pulse.SQLite` SQL scripts — out of scope for U08 but worth a single shared fix. +- The TUnit test invokes `dotnet pack` from inside the test process, which is heavy but acceptable for an audit-discovery test that runs once. Phase 3 may move this assertion to a build-time MSBuild target. diff --git a/audit/verification/round-01-U09.md b/audit/verification/round-01-U09.md new file mode 100644 index 00000000..f1377c81 --- /dev/null +++ b/audit/verification/round-01-U09.md @@ -0,0 +1,144 @@ +# U09 Verification + +**Status:** CONFIRMED + +**Evidence:** +- `src/NetEvolve.Pulse.AspNetCore/EndpointRouteBuilderExtensions.cs:226` — SSE branch: `JsonSerializer.Serialize(item)` (no options). +- `src/NetEvolve.Pulse.AspNetCore/EndpointRouteBuilderExtensions.cs:248` — NDJSON branch: `JsonSerializer.SerializeToUtf8Bytes(item)` (no options). +- `MapQuery` (`:142-148`) returns `TypedResults.Ok(...)`, which serializes via ASP.NET Core's `IOptions` (camelCase by default and honors `ConfigureHttpJsonOptions`). +- No `IPayloadSerializer` lookup, no `IOptions` resolution, no `JsonOptions` pull in the streaming helpers (`ExecuteStreamReadServerSentEvents`, `ExecuteStreamReadNdjson`). + +**Reasoning:** Two endpoints exposing the same DTO will serialize that DTO with *different* property casing: `MapQuery` honors `ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase)` and emits `{ "orderId": ... }`, while `MapStreamQuery` ignores the configuration and emits `{ "OrderId": ... }` (the C# property name, PascalCase). Beyond inconsistency, the raw `JsonSerializer.Serialize` call also blocks AOT trimming for stream endpoints (no `JsonSerializerContext` route, no `IPayloadSerializer` indirection). + +**Failing test (if confirmed):** +- Path: `tests/NetEvolve.Pulse.Tests.Unit/AspNetCore/MapStreamQueryJsonCasingTests.cs` +- Status: written + +```csharp +namespace NetEvolve.Pulse.Tests.Unit.AspNetCore; + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U09: MapStreamQuery uses raw .Serialize +/// without options, so configured JsonSerializerOptions (e.g. camelCase via +/// ConfigureHttpJsonOptions) are silently ignored. MapQuery honors them; the +/// two endpoints serialize the *same DTO* differently. +/// +[TestGroup("AspNetCore")] +public sealed class MapStreamQueryJsonCasingTests +{ + [Test] + public async Task MapQuery_and_MapStreamQuery_should_use_consistent_JSON_property_casing( + CancellationToken cancellationToken + ) + { + // ARRANGE — Host configured with camelCase via ConfigureHttpJsonOptions. + var host = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + _ = webBuilder.UseTestServer(); + _ = webBuilder.ConfigureServices(services => + { + _ = services.AddRouting(); + _ = services.ConfigureHttpJsonOptions(opts => + opts.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase + ); + _ = services.AddSingleton, EchoQueryHandler>(); + _ = services.AddSingleton, EchoStreamQueryHandler>(); + _ = services.AddPulse(_ => { }); + }); + _ = webBuilder.Configure(app => + { + _ = app.UseRouting(); + _ = app.UseEndpoints(endpoints => + { + _ = endpoints.MapQuery("/order"); + _ = endpoints.MapStreamQuery("/orders/stream"); + }); + }); + }) + .Build(); + + await using (host.ConfigureAwait(false)) + { + await host.StartAsync(cancellationToken).ConfigureAwait(false); + + var client = host.GetTestClient(); + + // ACT — Hit both endpoints; the stream endpoint emits NDJSON when asked. + var queryJson = await client + .GetStringAsync(new Uri("/order", UriKind.Relative), cancellationToken) + .ConfigureAwait(false); + + using var streamRequest = new HttpRequestMessage(HttpMethod.Get, "/orders/stream"); + streamRequest.Headers.Accept.ParseAdd("application/x-ndjson"); + var streamResponse = await client.SendAsync(streamRequest, cancellationToken).ConfigureAwait(false); + var streamJson = ( + await streamResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) + ).Trim(); + + // ASSERT — MapQuery uses camelCase (configured). MapStreamQuery must match. + using (Assert.Multiple()) + { + _ = await Assert.That(queryJson).Contains("\"orderId\""); + _ = await Assert.That(streamJson).Contains("\"orderId\""); + _ = await Assert.That(streamJson).DoesNotContain("\"OrderId\""); + } + } + } + + public sealed record OrderDto(string OrderId, int Quantity); + + public sealed record EchoQuery : IQuery + { + public string? CausationId { get; set; } + public string? CorrelationId { get; set; } + } + + public sealed record EchoStreamQuery : IStreamQuery + { + public string? CausationId { get; set; } + public string? CorrelationId { get; set; } + } + + private sealed class EchoQueryHandler : IQueryHandler + { + public Task HandleAsync(EchoQuery request, CancellationToken cancellationToken) => + Task.FromResult(new OrderDto("o-1", 42)); + } + + private sealed class EchoStreamQueryHandler : IStreamQueryHandler + { + public async IAsyncEnumerable HandleAsync( + EchoStreamQuery request, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + yield return new OrderDto("o-1", 42); + await Task.CompletedTask.ConfigureAwait(false); + } + } +} +``` + +**Notes:** +- Today: `queryJson` will be `{"orderId":"o-1","quantity":42}` (correct camelCase) but `streamJson` will be `{"OrderId":"o-1","Quantity":42}` (raw `JsonSerializer.Serialize` ignores configured options) → both `Contains("\"orderId\"")` for the stream and `DoesNotContain("\"OrderId\"")` fail. +- Phase 3 fix: inject `IOptions` (or resolve `IPayloadSerializer`) inside the endpoint delegate, pass `.SerializerOptions` to `JsonSerializer.Serialize/SerializeToUtf8Bytes`. The SSE branch on .NET 10 (`TypedResults.ServerSentEvents`) likely already honors `JsonOptions` — only the NDJSON branch (`ExecuteStreamReadNdjson`) and the pre-.NET-10 SSE fallback (`ExecuteStreamReadServerSentEvents`) need updating. +- Placed in `Tests.Integration` rather than `Tests.Unit` because it spins a TestServer, mirroring the existing `EndpointRouteBuilderExtensionsTests` patterns; the integration project already references AspNetCore and TestHost. Per the prompt, integration is an acceptable location. diff --git a/audit/verification/round-01-U10.md b/audit/verification/round-01-U10.md new file mode 100644 index 00000000..f895c5ee --- /dev/null +++ b/audit/verification/round-01-U10.md @@ -0,0 +1,34 @@ +# U10 Verification + +**Status:** CONFIRMED + +**Evidence:** +- `src/NetEvolve.Pulse.Kafka/Outbox/KafkaMessageTransport.cs:94` — `_ = _producer.Flush(Timeout.InfiniteTimeSpan);` inside `SendBatchAsync`. The `CancellationToken` parameter (line 63) is **never observed** in the method body; no `ThrowIfCancellationRequested`, no token-bound overload (`Flush(CancellationToken)` exists on `IProducer` but is not used). +- `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:432-435` — caller wraps `SendBatchAsync` with `CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)` and `CancelAfter(_options.ProcessingTimeout)`. The token cancellation never reaches the underlying broker call because `Flush(TimeSpan)` is infinite and ignores the token. + +**Reasoning:** A stuck broker — or any unreachable broker that the Confluent.Kafka client is still trying to flush — leaves the processor thread parked indefinitely. `ProcessingTimeout` is documented as the per-batch bound (XML doc at `OutboxProcessorHostedService.cs:421-423`) but cannot enforce that bound because `SendBatchAsync` does not propagate cancellation to `Flush`. The fix is mechanical: either call `_producer.Flush(cancellationToken)` (token overload exists on `IProducer`), or `Flush` with a bounded `TimeSpan` and check the token in a loop, throwing `OperationCanceledException` on cancellation. The existing `KafkaMessageTransportTests` already include a `FakeProducer` with a `Flush(TimeSpan)` and `Flush(CancellationToken)` pair, making the fake easy to extend. + +**Failing test (if confirmed):** +- Path: `tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportCancellationTests.cs` +- Status: written + +```csharp +namespace NetEvolve.Pulse.Tests.Unit.Kafka; + +// (See test file for full source.) +``` + +The test: + +1. Builds a `BlockingFlushProducer` whose `Flush(TimeSpan)` blocks indefinitely on a `ManualResetEventSlim` (mirrors a stuck broker). +2. Constructs a `KafkaMessageTransport` with it. +3. Creates a `CancellationTokenSource`, schedules cancellation after 100 ms. +4. Calls `SendBatchAsync(messages, cts.Token)` and asserts that the returned task **completes** (faulted or cancelled is fine) within 200 ms. + +Today the `SendBatchAsync` task never completes — the test waits the full assertion window (`WaitAsync(200ms)`) and the `Throws` assertion succeeds (proving the bug). Phase 3 fix flips the cancellation behavior; the assertion is updated to `IsCompletedSuccessfullyOrCancelled` after the fix. + +**Notes:** +- The test uses `Task.WaitAsync(TimeSpan)` to enforce the 200 ms ceiling without relying on the transport's own token observation — that's exactly the path the bug breaks. +- The fake `Produce` no-ops so message-enqueue does not block; only `Flush` blocks. This isolates U10 to the documented call site (`KafkaMessageTransport.cs:94`). +- After the Phase 3 fix, the test's expectation flips: replace the `TimeoutException` assertion with an `OperationCanceledException`/`TaskCanceledException` assertion (or assert the returned task is cancelled). Leave a `// TODO: U10 fix landed — flip assertion` comment in the test until the fix lands. +- A second pre-existing test (`SendBatchAsync_Enqueues_all_messages_and_flushes`) is unaffected — the new fake is local to this test file. diff --git a/tests/NetEvolve.Pulse.Tests.Unit/AspNetCore/MapStreamQueryJsonCasingTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/AspNetCore/MapStreamQueryJsonCasingTests.cs new file mode 100644 index 00000000..6aaf1cf7 --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/AspNetCore/MapStreamQueryJsonCasingTests.cs @@ -0,0 +1,123 @@ +namespace NetEvolve.Pulse.Tests.Unit.AspNetCore; + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Extensibility; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U09: MapStreamQuery uses raw .Serialize +/// without options, so configured JsonSerializerOptions (e.g. camelCase via +/// ConfigureHttpJsonOptions) are silently ignored. MapQuery honors them; the +/// two endpoints serialize the *same DTO* differently — PascalCase vs camelCase. +/// See audit/verification/round-01-U09.md. +/// +[TestGroup("AspNetCore")] +public sealed class MapStreamQueryJsonCasingTests +{ + [Test] + public async Task MapQuery_and_MapStreamQuery_should_use_consistent_JSON_property_casing( + CancellationToken cancellationToken + ) + { + // ARRANGE — Host configured with camelCase via ConfigureHttpJsonOptions. + using var host = await CreateTestHostAsync(cancellationToken).ConfigureAwait(false); + var client = host.GetTestClient(); + + // ACT — Hit both endpoints; the stream endpoint emits NDJSON when asked. + var queryJson = await client + .GetStringAsync(new Uri("/order", UriKind.Relative), cancellationToken) + .ConfigureAwait(false); + + using var streamRequest = new HttpRequestMessage(HttpMethod.Get, "/orders/stream"); + streamRequest.Headers.Accept.ParseAdd("application/x-ndjson"); + using var streamResponse = await client.SendAsync(streamRequest, cancellationToken).ConfigureAwait(false); + var streamJson = ( + await streamResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false) + ).Trim(); + + // ASSERT — MapQuery uses camelCase (configured). MapStreamQuery must match. + using (Assert.Multiple()) + { + _ = await Assert.That(queryJson).Contains("\"orderId\""); + _ = await Assert.That(streamJson).Contains("\"orderId\""); + _ = await Assert.That(streamJson).DoesNotContain("\"OrderId\""); + } + } + + private static async Task CreateTestHostAsync(CancellationToken cancellationToken) + { + var host = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + _ = webBuilder.UseTestServer(); + _ = webBuilder.ConfigureServices(services => + { + _ = services.AddRouting(); + _ = services.ConfigureHttpJsonOptions(opts => + opts.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase + ); + _ = services.AddSingleton, EchoQueryHandler>(); + _ = services.AddSingleton, EchoStreamQueryHandler>(); + _ = services.AddPulse(_ => { }); + }); + _ = webBuilder.Configure(app => + { + _ = app.UseRouting(); + _ = app.UseEndpoints(endpoints => + { + _ = endpoints.MapQuery("/order"); + _ = endpoints.MapStreamQuery("/orders/stream"); + }); + }); + }) + .Build(); + + await host.StartAsync(cancellationToken).ConfigureAwait(false); + return host; + } + + private sealed record OrderDto(string OrderId, int Quantity); + + private sealed record EchoQuery : IQuery + { + public string? CausationId { get; set; } + public string? CorrelationId { get; set; } + } + + private sealed record EchoStreamQuery : IStreamQuery + { + public string? CausationId { get; set; } + public string? CorrelationId { get; set; } + } + + private sealed class EchoQueryHandler : IQueryHandler + { + public Task HandleAsync(EchoQuery request, CancellationToken cancellationToken = default) => + Task.FromResult(new OrderDto("o-1", 42)); + } + + private sealed class EchoStreamQueryHandler : IStreamQueryHandler + { + public async IAsyncEnumerable HandleAsync( + EchoStreamQuery request, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + yield return new OrderDto("o-1", 42); + await Task.CompletedTask.ConfigureAwait(false); + } + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusReadmeQuickStartTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusReadmeQuickStartTests.cs new file mode 100644 index 00000000..c9ebf969 --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusReadmeQuickStartTests.cs @@ -0,0 +1,87 @@ +namespace NetEvolve.Pulse.Tests.Unit.AzureServiceBus; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility.Outbox; +using NetEvolve.Pulse.Outbox; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U07: Building a service collection that follows +/// src/NetEvolve.Pulse.AzureServiceBus/README.md:32-45 verbatim must surface +/// a clear, actionable error — pointing the user at the missing AddOutbox() +/// / persistence provider call — rather than silently leaving the outbox half-wired. +/// +/// Currently FAILS: the provider builds without error, IOutboxRepository resolution +/// throws a generic "No service for type … IOutboxRepository …" message that does not +/// mention AddOutbox, AddSqlServerOutbox, or any actionable remediation, and +/// OutboxProcessorHostedService is never registered. +/// +[TestGroup("AzureServiceBus")] +public sealed class AzureServiceBusReadmeQuickStartTests +{ + private const string FakeConnectionString = + "Endpoint=sb://localhost/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=Fake="; + + [Test] + public async Task AsbReadmeQuickStart_should_register_IOutboxRepository_or_throw_actionable_DI_error() + { + // ARRANGE — Verbatim transcription of README:32-45 quick-start. + var services = new ServiceCollection(); + _ = services.AddPulse(config => + config.UseAzureServiceBusTransport(options => + { + options.ConnectionString = FakeConnectionString; + options.EnableBatching = true; + }) + ); + + // ACT — Build the provider and try to resolve the persistence side. + var provider = services.BuildServiceProvider(); + await using (provider.ConfigureAwait(false)) + { + // ASSERT (1) — Either IOutboxRepository is registered (preferred), or + // resolving it throws an exception that *mentions* the missing call. + Exception? repositoryError = null; + try + { + _ = provider.GetRequiredService(); + } + catch (Exception ex) + { + repositoryError = ex; + } + + if (repositoryError is not null) + { + var message = repositoryError.Message; + var mentionsRemediation = + message.Contains("AddOutbox", StringComparison.OrdinalIgnoreCase) + || message.Contains("persistence", StringComparison.OrdinalIgnoreCase) + || message.Contains("provider", StringComparison.OrdinalIgnoreCase); + + _ = await Assert.That(mentionsRemediation).IsTrue(); + } + + // ASSERT (2) — OutboxProcessorHostedService must be wired up so the transport + // is actually drained; ASB-only registration without AddOutbox leaves it absent. + var hostedServices = provider.GetServices(); + var hasOutboxProcessor = false; + foreach (var hosted in hostedServices) + { + if (hosted is OutboxProcessorHostedService) + { + hasOutboxProcessor = true; + break; + } + } + + _ = await Assert.That(hasOutboxProcessor).IsTrue(); + } + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportCancellationTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportCancellationTests.cs new file mode 100644 index 00000000..69ef90d0 --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportCancellationTests.cs @@ -0,0 +1,268 @@ +namespace NetEvolve.Pulse.Tests.Unit.Kafka; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Confluent.Kafka; +using Confluent.Kafka.Admin; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Extensibility.Outbox; +using NetEvolve.Pulse.Outbox; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U10: ignores its +/// and calls _producer.Flush(Timeout.InfiniteTimeSpan). +/// When the broker is stuck, the call never returns even after the caller cancels the token. +/// See audit/verification/round-01-U10.md. +/// +[TestGroup("Kafka")] +public sealed class KafkaMessageTransportCancellationTests +{ + [Test] + public async Task SendBatchAsync_should_observe_cancellation_within_a_bounded_time_when_Flush_is_stuck() + { + // ARRANGE — Producer whose Flush blocks indefinitely on a manual reset event. + using var flushBarrier = new ManualResetEventSlim(initialState: false); + using var producer = new BlockingFlushProducer(flushBarrier); + using var admin = new InertAdminClient(); + + var transport = new KafkaMessageTransport(producer, admin, new ConstantTopicNameResolver("topic")); + var messages = Enumerable.Range(0, 2).Select(_ => CreateOutboxMessage()).ToArray(); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(100)); + + // ACT — Start the batch; assert it returns within 200ms even though Flush is stuck. + var sendBatch = transport.SendBatchAsync(messages, cts.Token); + + try + { + // WaitAsync enforces the 200ms ceiling externally — it does NOT depend on + // the transport actually observing the token. If WaitAsync throws + // TimeoutException, that proves the transport never returned in time. + await sendBatch.WaitAsync(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Acceptable: cancellation propagated correctly. + } + catch (TimeoutException) + { + // Today: WaitAsync throws TimeoutException because Flush never returns. + // Phase 3 fix should make this branch unreachable; this assertion fails today. + _ = await Assert.That(sendBatch.IsCompleted).IsTrue(); + throw; + } + finally + { + // Unblock the fake Flush so the background task can drain (best-effort cleanup). + flushBarrier.Set(); + } + + // ASSERT — After the await, the task must be in a terminal state. + _ = await Assert.That(sendBatch.IsCompleted).IsTrue(); + } + + private static OutboxMessage CreateOutboxMessage() => + new() + { + Id = Guid.NewGuid(), + EventType = typeof(TestKafkaEvent), + Payload = """{"event":"sample"}""", + CorrelationId = "corr-1", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + RetryCount = 0, + }; + + private sealed record TestKafkaEvent : IEvent + { + public string? CausationId { get; set; } + public string? CorrelationId { get; set; } + public string Id { get; init; } = Guid.NewGuid().ToString(); + public DateTimeOffset? PublishedAt { get; set; } + } + + private sealed class ConstantTopicNameResolver(string topic) : ITopicNameResolver + { + public string Resolve(OutboxMessage message) => topic; + } + + private sealed class BlockingFlushProducer : IProducer + { + private readonly ManualResetEventSlim _flushBarrier; + + public BlockingFlushProducer(ManualResetEventSlim flushBarrier) => _flushBarrier = flushBarrier; + + public string Name => "blocking-flush-producer"; + public Handle Handle => default!; + + public Task> ProduceAsync( + string topic, + Message message, + CancellationToken cancellationToken = default + ) => throw new NotSupportedException(); + + public Task> ProduceAsync( + TopicPartition topicPartition, + Message message, + CancellationToken cancellationToken = default + ) => throw new NotSupportedException(); + + public void Produce( + string topic, + Message message, + Action>? deliveryHandler = null + ) + { + // Enqueue is a no-op for this test — only Flush is exercised. + } + + public void Produce( + TopicPartition topicPartition, + Message message, + Action>? deliveryHandler = null + ) => throw new NotSupportedException(); + + public int Flush(TimeSpan timeout) + { + // Mirrors a stuck broker. Wait without observing any token — that is the bug. + _flushBarrier.Wait(); + return 0; + } + + public void Flush(CancellationToken cancellationToken = default) => _flushBarrier.Wait(cancellationToken); + + public int Poll(TimeSpan timeout) => 0; + + public void InitTransactions(TimeSpan timeout) { } + + public void BeginTransaction() { } + + public void CommitTransaction(TimeSpan timeout) { } + + public void CommitTransaction() { } + + public void AbortTransaction(TimeSpan timeout) { } + + public void AbortTransaction() { } + + public void SendOffsetsToTransaction( + IEnumerable offsets, + IConsumerGroupMetadata groupMetadata, + TimeSpan timeout + ) => throw new NotSupportedException(); + + public int AddBrokers(string brokers) => 0; + + public void SetSaslCredentials(string username, string password) { } + + public void Dispose() { } + } + + private sealed class InertAdminClient : IAdminClient + { + public string Name => "inert-admin"; + public Handle Handle => default!; + + public Metadata GetMetadata(TimeSpan timeout) => new([], [], -1, "cluster"); + + public Metadata GetMetadata(string topic, TimeSpan timeout) => throw new NotSupportedException(); + + public List ListGroups(TimeSpan timeout) => throw new NotSupportedException(); + + public GroupInfo ListGroup(string group, TimeSpan timeout) => throw new NotSupportedException(); + + public Task CreateTopicsAsync(IEnumerable topics, CreateTopicsOptions? options = null) => + throw new NotSupportedException(); + + public Task DeleteTopicsAsync(IEnumerable topics, DeleteTopicsOptions? options = null) => + throw new NotSupportedException(); + + public Task CreatePartitionsAsync( + IEnumerable partitionsSpecifications, + CreatePartitionsOptions? options = null + ) => throw new NotSupportedException(); + + public Task DeleteGroupsAsync(IList groups, DeleteGroupsOptions? options = null) => + throw new NotSupportedException(); + + public Task AlterConfigsAsync( + Dictionary> configs, + AlterConfigsOptions? options = null + ) => throw new NotSupportedException(); + + public Task> IncrementalAlterConfigsAsync( + Dictionary> configs, + IncrementalAlterConfigsOptions? options = null + ) => throw new NotSupportedException(); + + public Task> DescribeConfigsAsync( + IEnumerable resources, + DescribeConfigsOptions? options = null + ) => throw new NotSupportedException(); + + public Task> DeleteRecordsAsync( + IEnumerable topicPartitionOffsets, + DeleteRecordsOptions? options = null + ) => throw new NotSupportedException(); + + public Task CreateAclsAsync(IEnumerable aclBindings, CreateAclsOptions? options = null) => + throw new NotSupportedException(); + + public Task DescribeAclsAsync( + AclBindingFilter aclBindingFilter, + DescribeAclsOptions? options = null + ) => throw new NotSupportedException(); + + public Task> DeleteAclsAsync( + IEnumerable aclBindingFilters, + DeleteAclsOptions? options = null + ) => throw new NotSupportedException(); + + public Task DeleteConsumerGroupOffsetsAsync( + string group, + IEnumerable partitions, + DeleteConsumerGroupOffsetsOptions? options = null + ) => throw new NotSupportedException(); + + public Task> AlterConsumerGroupOffsetsAsync( + IEnumerable groupPartitions, + AlterConsumerGroupOffsetsOptions? options = null + ) => throw new NotSupportedException(); + + public Task> ListConsumerGroupOffsetsAsync( + IEnumerable groupPartitions, + ListConsumerGroupOffsetsOptions? options = null + ) => throw new NotSupportedException(); + + public Task ListConsumerGroupsAsync(ListConsumerGroupsOptions? options = null) => + throw new NotSupportedException(); + + public Task DescribeConsumerGroupsAsync( + IEnumerable groups, + DescribeConsumerGroupsOptions? options = null + ) => throw new NotSupportedException(); + + public Task DescribeUserScramCredentialsAsync( + IEnumerable users, + DescribeUserScramCredentialsOptions? options = null + ) => throw new NotSupportedException(); + + public Task AlterUserScramCredentialsAsync( + IEnumerable alterations, + AlterUserScramCredentialsOptions? options = null + ) => throw new NotSupportedException(); + + public int AddBrokers(string brokers) => 0; + + public void SetSaslCredentials(string username, string password) { } + + public void Dispose() { } + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorOptionsDefaultsTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorOptionsDefaultsTests.cs new file mode 100644 index 00000000..6e49147f --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorOptionsDefaultsTests.cs @@ -0,0 +1,43 @@ +namespace NetEvolve.Pulse.Tests.Unit.Outbox; + +using System.Threading.Tasks; +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Outbox; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U06: Asserts the *desirable* defaults for . +/// Currently FAILS — defaults are configured as a foot-gun: +/// MaxRetryCount=3, EnableExponentialBackoff=false, EnableBatchSending=false. +/// Phase 3 should either flip these to the safer values asserted below or, if the current +/// values are intentional, repurpose this test (and update its assertions/message) as the +/// documented-choice contract test. +/// +[TestGroup("Outbox")] +public sealed class OutboxProcessorOptionsDefaultsTests +{ + [Test] + public async Task Default_MaxRetryCount_should_be_at_least_5_to_survive_transient_failures() + { + var options = new OutboxProcessorOptions(); + + _ = await Assert.That(options.MaxRetryCount).IsGreaterThanOrEqualTo(5); + } + + [Test] + public async Task Default_EnableExponentialBackoff_should_be_true_to_avoid_thundering_herd() + { + var options = new OutboxProcessorOptions(); + + _ = await Assert.That(options.EnableExponentialBackoff).IsTrue(); + } + + [Test] + public async Task Default_EnableBatchSending_should_be_true_for_efficient_transport() + { + var options = new OutboxProcessorOptions(); + + _ = await Assert.That(options.EnableBatchSending).IsTrue(); + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxScriptPackagingTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxScriptPackagingTests.cs new file mode 100644 index 00000000..0acdba6a --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxScriptPackagingTests.cs @@ -0,0 +1,104 @@ +namespace NetEvolve.Pulse.Tests.Unit.SqlServer; + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using NetEvolve.Extensions.TUnit; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +/// +/// Phase 2 audit U08: OutboxMessage.sql must be reachable from PackageReference +/// consumers. Today it is packed under content\Scripts\ (legacy packages.config +/// mechanism) which does not flow to modern SDK-style consumers. +/// See audit/verification/round-01-U08.md. +/// +[TestGroup("SqlServer")] +public sealed class SqlServerOutboxScriptPackagingTests +{ + [Test] + [SuppressMessage( + "Performance", + "CA1849:Call async methods when in an async method", + Justification = "ZipFile.OpenReadAsync is .NET 10 only; this audit repro must build on net8/net9 too." + )] + [SuppressMessage( + "Major Code Smell", + "S6966:Awaitable method should be used", + Justification = "Same reason as CA1849 — ZipFile.OpenReadAsync is not available on all TFMs." + )] + public async Task OutboxMessage_sql_must_be_reachable_from_PackageReference_consumers() + { + // ARRANGE — Locate the SqlServer csproj relative to repo root. + var repoRoot = LocateRepoRoot(); + var csproj = Path.Combine(repoRoot, "src", "NetEvolve.Pulse.SqlServer", "NetEvolve.Pulse.SqlServer.csproj"); + + _ = await Assert.That(File.Exists(csproj)).IsTrue(); + + // ACT — Pack into a scratch folder. + var outputDir = Path.Combine(Path.GetTempPath(), $"pulse-u08-{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(outputDir); + + try + { + var psi = new ProcessStartInfo("dotnet", $"pack \"{csproj}\" -o \"{outputDir}\" --nologo") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = Process.Start(psi)!; + await process.WaitForExitAsync().ConfigureAwait(false); + + _ = await Assert.That(process.ExitCode).IsEqualTo(0); + + // ASSERT — The nupkg must expose OutboxMessage.sql via contentFiles/ or + // build*/buildTransitive/ (anything that flows to PackageReference consumers). + // content\ alone does NOT — that is the legacy packages.config path. + var nupkg = Directory.EnumerateFiles(outputDir, "NetEvolve.Pulse.SqlServer.*.nupkg").FirstOrDefault(); + + _ = await Assert.That(nupkg).IsNotNull(); + + using var archive = ZipFile.OpenRead(nupkg!); + var entries = archive.Entries.Select(e => e.FullName.Replace('\\', '/')).ToArray(); + + var reachableFromPackageReference = entries.Any(e => + e.EndsWith("/OutboxMessage.sql", StringComparison.OrdinalIgnoreCase) + && ( + e.StartsWith("contentFiles/", StringComparison.OrdinalIgnoreCase) + || e.StartsWith("build/", StringComparison.OrdinalIgnoreCase) + || e.StartsWith("buildTransitive/", StringComparison.OrdinalIgnoreCase) + ) + ); + + _ = await Assert.That(reachableFromPackageReference).IsTrue(); + } + finally + { + try + { + Directory.Delete(outputDir, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } + } + + private static string LocateRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Pulse.slnx"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new InvalidOperationException("Pulse.slnx not located."); + } +}