elsa-core/test/unit/Elsa.UserTasks.Persistence.ConformanceTests/UserTaskConformanceTestBase.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

109 lines
4.6 KiB
C#

using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// Shared arrangement for every conformance class.
///
/// The stores are shared for the whole provider collection so a container-backed provider is migrated once,
/// and each test isolates itself with its own tenant instead. Every contract except the deliberately
/// tenant-agnostic invitation-hash lookup is tenant-scoped, so this is isolation, not a shortcut.
/// </summary>
public abstract class UserTaskConformanceTestBase(UserTaskStoreFixture fixture)
{
private int _sequence;
protected UserTaskStoreFixture Fixture { get; } = fixture;
protected IUserTaskRepository Repository => Fixture.Repository;
protected UserTaskStoreFixture.TestClock Clock => Fixture.Clock;
/// <summary>This test's private tenant. Never reused, so a shared store still gives per-test isolation.</summary>
protected string TenantId { get; } = $"tenant-{Guid.NewGuid():N}";
protected Task ActivateAsync() => Fixture.ActivateAsync();
protected ParticipantReference Subject(string id = "user-1") => new(TenantId, "oidc", UserTaskParticipantType.User, id);
protected ParticipantReference Group(string id) => new(TenantId, "oidc", UserTaskParticipantType.Group, id);
/// <summary>Builds a task in this test's tenant with store-unique keys, ready for <c>AddProjectionAsync</c>.</summary>
protected UserTask CreateTask(
ParticipantReference? candidate = null,
string title = "Approve request",
int priority = 50,
DateTimeOffset? dueAt = null,
DateTimeOffset? createdAt = null)
{
var ordinal = ++_sequence;
var created = createdAt ?? Clock.UtcNow.AddMinutes(ordinal);
return new()
{
// Ordinal-prefixed so the identity tiebreaker is predictable and a failure is readable.
Id = $"task-{ordinal:D4}-{Guid.NewGuid():N}",
TenantId = TenantId,
WorkflowDefinitionId = "definition-1",
WorkflowInstanceId = "instance-1",
ActivityInstanceId = "activity-1",
BookmarkId = $"bookmark-{Guid.NewGuid():N}",
MaterializationKey = $"materialization-{Guid.NewGuid():N}",
Title = title,
Summary = "Review the request",
Tags = ["finance"],
Priority = priority,
DueAt = dueAt,
CandidateUsers = candidate is null ? [] : [candidate],
InvitationDefinitions = [new UserTaskInvitationDefinition("bearer", ["Complete"], BearerOnly: true)],
CreatedAt = created,
UpdatedAt = created
};
}
/// <summary>Projects a task and returns the stored copy, so a test starts from committed state in one line.</summary>
protected async Task<UserTask> ProjectAsync(UserTask task)
{
await Repository.AddProjectionAsync(task);
return await Repository.GetAsync(task.TenantId, task.Id)
?? throw new InvalidOperationException($"The projection of '{task.Id}' was not readable afterwards.");
}
protected async Task<UserTask> GetAsync(string taskId) =>
await Repository.GetAsync(TenantId, taskId) ?? throw new InvalidOperationException($"Task '{taskId}' was not found.");
protected UserTaskQuery Query(
UserTaskQueryScopeKind kind = UserTaskQueryScopeKind.Available,
ParticipantReference? subject = null,
int limit = 50,
string sort = "created",
bool descending = false,
bool includeTotalCount = false,
string? cursor = null) => new()
{
TenantId = TenantId,
Limit = limit,
Sort = sort,
Descending = descending,
IncludeTotalCount = includeTotalCount,
Cursor = cursor,
Scope = new(TenantId, subject ?? Subject(), [], Kind: kind)
};
/// <summary>Pages a query to exhaustion through its cursors and returns the ids in the order seen.</summary>
protected async Task<IReadOnlyList<string>> PageThroughAsync(UserTaskQuery query, int pageSize)
{
var seen = new List<string>();
string? cursor = null;
for (var page = 0; page < 100; page++)
{
var result = await Repository.QueryAsync(query with { Limit = pageSize, Cursor = cursor });
seen.AddRange(result.Items.Select(x => x.Id));
if (result.NextCursor is null)
return seen;
cursor = result.NextCursor;
}
throw new InvalidOperationException("The cursor never terminated; paging looped past 100 pages.");
}
}