elsa-core/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/ConformanceCoverageTests.cs
Sipke Schoorstra f6d2d38536
test(user-tasks): add a persistence conformance suite with fault injection (#7986)
Runs one suite unchanged against every implementation of IUserTaskRepository,
IUserTaskGuestSessionIssuer, and IUserTaskInvitationOutbox, plus a fault-injection
suite driving the real DefaultUserTaskManager and DefaultUserTaskInvitationService
against a real store. Gated providers report as skipped with a reason rather than
passing vacuously; ConformanceCoverageTests fails when a provider that must run is
unreachable or its variable is set but empty.

The suite found three defects, fixed here:

- VNextUserTaskRepository supplied no index values for WorkflowDefinitionId,
  WorkflowInstanceId, ActivityInstanceId, CreatedAt, or CompletedAt, all declared by
  its own schema provider, so every write through the VNext provider threw.
- The same provider resolved invitation token hashes by scanning on Status alone,
  which matched no declared index, so anonymous invitation verification always threw.
- EFCoreUserTaskInvitationOutbox persisted the delivery recipient but never read it
  back, so durably queued invitations reached the dispatcher with no address.

Also switches new ADRs to date-prefixed identifiers and generates doc/adr/toc.md via
scripts/adr/generate-toc.sh, with a --check mode and pull-request workflow so the
index is never hand-edited again.
2026-08-25 04:36:18 +02:00

97 lines
4 KiB
C#

using System.Text;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Xunit.Abstractions;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// States, in one always-running place, which providers this run actually covered.
///
/// A silently skipped provider reads as covered when it is not, which is how a persistence defect reaches
/// production behind a provider nobody ran. These tests fail when a provider that should have run did not,
/// and otherwise write the full matrix to the test output and to an artifact file.
/// </summary>
public sealed class ConformanceCoverageTests(ITestOutputHelper output)
{
/// <summary>Providers that must run everywhere, including on a developer machine with no containers.</summary>
private static readonly string[] RequiredProviders =
[
ConformanceProviders.InMemory,
ConformanceProviders.Sqlite,
ConformanceProviders.VNext
];
[Fact]
public void EveryProviderThatMustRunIsReachable()
{
var unreachable = RequiredProviders
.Select(ConformanceProviders.Get)
.Where(x => !x.IsAvailable)
.Select(x => $"{x.Name}: {x.SkipReason}")
.ToList();
Assert.True(unreachable.Count == 0,
"These providers must run in every conformance run but did not:" + Environment.NewLine + string.Join(Environment.NewLine, unreachable));
}
[Fact]
public void AProviderRequestedByTheEnvironmentIsNotQuietlyIgnored()
{
// Setting the variable to whitespace is the failure mode worth catching: it looks configured on the
// CI job and gates nothing, so the provider reports as skipped while the operator believes it ran.
var misconfigured = ConformanceProviders.All
.Where(x => x.ConnectionStringVariable is not null && !x.IsAvailable)
.Where(x => Environment.GetEnvironmentVariable(x.ConnectionStringVariable!) is not null)
.Select(x => $"{x.Name}: {x.ConnectionStringVariable} is set but empty.")
.ToList();
Assert.True(misconfigured.Count == 0, string.Join(Environment.NewLine, misconfigured));
}
[Fact]
public void TheCoverageMatrixIsReported()
{
var report = BuildReport();
output.WriteLine(report);
var path = Path.Join(AppContext.BaseDirectory, "user-task-conformance-coverage.md");
File.WriteAllText(path, report);
Assert.Contains("| Provider |", report, StringComparison.Ordinal);
}
private static string BuildReport()
{
var builder = new StringBuilder();
builder.AppendLine("# User Tasks persistence conformance coverage").AppendLine();
builder.AppendLine("| Provider | Repository | Guest sessions | Outbox | Status |");
builder.AppendLine("| --- | --- | --- | --- | --- |");
foreach (var provider in ConformanceProviders.All)
{
var status = provider.IsAvailable ? "covered" : $"**not covered** — {provider.SkipReason}";
builder.AppendLine($"| {provider.Name} | {Mark(provider.Name, Contract.Repository)} | {Mark(provider.Name, Contract.GuestSessions)} | {Mark(provider.Name, Contract.Outbox)} | {status} |");
}
builder.AppendLine();
builder.AppendLine("`yes` means the suite ran against that contract in this run. A blank cell means the provider");
builder.AppendLine("ships no implementation of it; `no` means it has one that this run did not exercise.");
return builder.ToString();
}
private static string Mark(string providerName, Contract contract)
{
// VNext ships a repository only. Saying so beats leaving it to be inferred from an absent class.
if (providerName == ConformanceProviders.VNext && contract != Contract.Repository)
return "n/a";
return ConformanceProviders.Get(providerName).IsAvailable ? "yes" : "no";
}
private enum Contract
{
Repository,
GuestSessions,
Outbox
}
}