* fix(user-tasks): align query sort and cursor contract across providers Implement the REST updated sort on EF and VNext, drop the InMemory-only completed sort, and use one always-ascending Id tiebreaker plus the production JSON base64url cursor codec in every repository. Closes #8110 Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(user-tasks): keep title cursor ties comparison-consistent Use the same string.Compare relation for title ordering and cursor tie detection so culture-equal Unicode forms are not skipped. Assert updated sort order directly so a created fallback cannot pass. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(user-tasks): use one ordinal comparer for title sort and cursors OrderBy, title-tie detection, and cursor filtering now share StringComparer.Ordinal on the LINQ-to-objects providers so Unicode variants cannot skip a page. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(user-tasks): share default title comparison across providers InMemory and VNext title sort/cursors now use the same OrderBy plus string.Compare relation as EF (SQL collation / current culture) so a title cursor stays portable. Ties still use Compare == 0, not ordinal ==. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * docs(user-tasks): title cursors are provider-scoped, not portable Restore InMemory/VNext ordinal title OrderBy/tie/cursor (EF stays column-collation). Document that title pages must be recreated after a provider or collation change; other sorts plus Id stay portable. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
388 lines
20 KiB
C#
388 lines
20 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Elsa.Authorization;
|
|
using Elsa.UserTasks.Models;
|
|
using Elsa.UserTasks.Options;
|
|
using Elsa.UserTasks.Permissions;
|
|
using Elsa.UserTasks.Repositories;
|
|
using Elsa.UserTasks.Services;
|
|
using Xunit;
|
|
|
|
namespace Elsa.UserTasks.UnitTests;
|
|
|
|
public class UserTaskTests
|
|
{
|
|
private readonly UserTaskTestFixture _fixture = new();
|
|
|
|
[Fact]
|
|
public async Task ClaimsResolver_PreservesExternalGroupClaimValues()
|
|
{
|
|
var resolver = new DefaultClaimsIdentityResolver(Microsoft.Extensions.Options.Options.Create(new UserTasksOptions { DefaultTenantId = "tenant", DefaultProvider = "oidc" }));
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity([
|
|
new Claim("sub", "user-1"),
|
|
new Claim("groups", "group,with;delimiters"),
|
|
new Claim("groups", "finance"),
|
|
new Claim("permission", UserTaskTestFixture.Grant(CoreVerbs.View))
|
|
], "test"));
|
|
|
|
var actor = await resolver.ResolveAsync(principal);
|
|
|
|
Assert.NotNull(actor);
|
|
Assert.Contains(actor.Groups, x => x.Id == "group,with;delimiters");
|
|
Assert.Contains(actor.Groups, x => x.Id == "finance");
|
|
Assert.Equal("tenant", actor.Subject.TenantId);
|
|
Assert.Equal("oidc", actor.Subject.Provider);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repository_CursorIsStableAndTotalCountIgnoresCursor()
|
|
{
|
|
var repository = new InMemoryUserTaskRepository();
|
|
for (var i = 0; i < 3; i++)
|
|
await repository.AddProjectionAsync(new() { Id = $"task-{i}", TenantId = "tenant", Title = $"Task {i}", CreatedAt = DateTimeOffset.UtcNow.AddMinutes(i) });
|
|
|
|
var first = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, IncludeTotalCount = true });
|
|
var second = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Cursor = first.NextCursor, IncludeTotalCount = true });
|
|
|
|
Assert.Equal(3, first.TotalCount);
|
|
Assert.Equal(3, second.TotalCount);
|
|
Assert.Single(second.Items);
|
|
Assert.DoesNotContain(first.Items.Select(x => x.Id), x => second.Items.Any(y => y.Id == x));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("created", false)]
|
|
[InlineData("created", true)]
|
|
[InlineData("due", false)]
|
|
[InlineData("due", true)]
|
|
[InlineData("priority", false)]
|
|
[InlineData("priority", true)]
|
|
[InlineData("title", false)]
|
|
[InlineData("title", true)]
|
|
[InlineData("updated", false)]
|
|
[InlineData("updated", true)]
|
|
public async Task Repository_CursorCoversSupportedSortsAndDirections(string sort, bool descending)
|
|
{
|
|
var repository = new InMemoryUserTaskRepository();
|
|
var now = DateTimeOffset.UtcNow;
|
|
await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "Alpha", Priority = 10, DueAt = now.AddHours(1), CreatedAt = now.AddMinutes(1), UpdatedAt = now.AddHours(3) });
|
|
await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "Beta", Priority = 50, DueAt = null, CreatedAt = now.AddMinutes(2), UpdatedAt = now.AddHours(1) });
|
|
await repository.AddProjectionAsync(new() { Id = "task-c", TenantId = "tenant", Title = "Gamma", Priority = 90, DueAt = now.AddHours(2), CreatedAt = now.AddMinutes(3), UpdatedAt = now.AddHours(2) });
|
|
|
|
var first = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Sort = sort, Descending = descending, IncludeTotalCount = true });
|
|
var second = await repository.QueryAsync(new() { TenantId = "tenant", Limit = 2, Sort = sort, Descending = descending, Cursor = first.NextCursor, IncludeTotalCount = true });
|
|
var ids = first.Items.Concat(second.Items).Select(x => x.Id).ToArray();
|
|
|
|
Assert.Equal(3, first.TotalCount);
|
|
Assert.Equal(3, second.TotalCount);
|
|
Assert.Equal(3, ids.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repository_DescendingSortUsesAscendingIdTiebreaker()
|
|
{
|
|
var repository = new InMemoryUserTaskRepository();
|
|
var now = DateTimeOffset.UtcNow;
|
|
await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Priority = 50, CreatedAt = now });
|
|
await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Priority = 50, CreatedAt = now });
|
|
|
|
var page = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "priority", Descending = true, Limit = 10 });
|
|
|
|
Assert.Equal(["task-a", "task-b"], page.Items.Select(x => x.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repository_TitleCursorUsesTheSameComparisonForTies()
|
|
{
|
|
var repository = new InMemoryUserTaskRepository();
|
|
await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "Same" });
|
|
await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "Same" });
|
|
|
|
var first = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1 });
|
|
var second = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1, Cursor = first.NextCursor });
|
|
|
|
Assert.Equal("task-a", Assert.Single(first.Items).Id);
|
|
Assert.Equal("task-b", Assert.Single(second.Items).Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repository_TitleCursorDoesNotDropUnicodeVariantTitles()
|
|
{
|
|
var repository = new InMemoryUserTaskRepository();
|
|
await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", Title = "caf\u00E9" });
|
|
await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", Title = "cafe\u0301" });
|
|
|
|
// InMemory title sort is ordinal: NFC/NFD café are distinct keys and must both
|
|
// appear. Recreate the list after switching to an EF collation host.
|
|
var first = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1 });
|
|
var second = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "title", Limit = 1, Cursor = first.NextCursor });
|
|
|
|
Assert.Equal(2, first.Items.Concat(second.Items).Select(x => x.Id).Distinct().Count());
|
|
Assert.Null(second.NextCursor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repository_UnknownSortCompletedUsesCreatedOrder()
|
|
{
|
|
var repository = new InMemoryUserTaskRepository();
|
|
var now = DateTimeOffset.UtcNow;
|
|
await repository.AddProjectionAsync(new() { Id = "task-a", TenantId = "tenant", CreatedAt = now.AddMinutes(1), CompletedAt = now.AddHours(3) });
|
|
await repository.AddProjectionAsync(new() { Id = "task-b", TenantId = "tenant", CreatedAt = now.AddMinutes(2), CompletedAt = now.AddHours(1) });
|
|
|
|
var created = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "created", Limit = 10 });
|
|
var completed = await repository.QueryAsync(new() { TenantId = "tenant", Sort = "completed", Limit = 10 });
|
|
|
|
Assert.Equal(created.Items.Select(x => x.Id), completed.Items.Select(x => x.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Manager_HidesProtectedFieldsUntilClaimAndCompletesAfterBookmarkFinalization()
|
|
{
|
|
var actor = _fixture.Actor("user-1");
|
|
var task = await _fixture.ProjectAsync(actor.Subject);
|
|
|
|
var candidateDetail = await _fixture.Manager.GetAsync(UserTaskTestFixture.TenantId, task.Id, actor);
|
|
Assert.NotNull(candidateDetail);
|
|
Assert.Null(candidateDetail.Instructions);
|
|
Assert.False(candidateDetail.Disclosure.CanViewProtected);
|
|
|
|
var claimed = await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), actor);
|
|
Assert.True(claimed.Accepted);
|
|
var assignedDetail = await _fixture.Manager.GetAsync(UserTaskTestFixture.TenantId, task.Id, actor);
|
|
Assert.Equal("private instructions", assignedDetail!.Instructions);
|
|
Assert.True(assignedDetail.Disclosure.CanViewProtected);
|
|
Assert.NotEmpty(assignedDetail.Actions);
|
|
|
|
var completing = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Approve"), actor);
|
|
Assert.True(completing.Accepted);
|
|
Assert.Equal(UserTaskStatus.Completing, completing.Task.Status);
|
|
Assert.Equal(actor.Subject, _fixture.Resumer.LastStimulus!.CompletedBy);
|
|
|
|
var retry = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Approve"), actor);
|
|
Assert.True(retry.Accepted);
|
|
Assert.Equal(completing.Operation.OperationId, retry.Operation.OperationId);
|
|
|
|
var divergent = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Reject"), actor);
|
|
Assert.False(divergent.Accepted);
|
|
Assert.Equal("idempotency-conflict", divergent.ConflictCode);
|
|
|
|
await _fixture.Projection.FinalizeBookmarkRemovalAsync(new(UserTaskTestFixture.TenantId, task.Id, task.BookmarkId, _fixture.Clock.UtcNow));
|
|
var completed = await _fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id);
|
|
Assert.Equal(UserTaskStatus.Completed, completed!.Status);
|
|
|
|
var terminalRetry = await _fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id, new(claimed.Task.Revision, "complete-1", "Approve"), actor);
|
|
Assert.True(terminalRetry.Accepted);
|
|
Assert.Equal(completing.Operation.OperationId, terminalRetry.Operation.OperationId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Policy_RequiresOperationPermissionInAdditionToCandidateRelationship()
|
|
{
|
|
var actor = _fixture.Actor("user-1", UserTaskTestFixture.Grant(CoreVerbs.View));
|
|
var task = await _fixture.ProjectAsync(actor.Subject);
|
|
|
|
var claim = await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-no-permission"), actor);
|
|
|
|
Assert.False(claim.Accepted);
|
|
Assert.Equal("forbidden", claim.ConflictCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Policy_ManagerFlagAloneDoesNotGrantManagementWithoutThePermission()
|
|
{
|
|
var actor = _fixture.Actor("user-1", UserTaskTestFixture.Grant(CoreVerbs.View), UserTaskTestFixture.Grant(UserTaskVerbs.Assign)) with { IsManager = true };
|
|
var candidate = _fixture.Actor("user-2");
|
|
var task = await _fixture.ProjectAsync(candidate.Subject);
|
|
|
|
// The host set IsManager but never granted user-tasks:supervise, so management must still be refused.
|
|
Assert.False(await _fixture.Policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.Assign));
|
|
Assert.Null(await _fixture.Policy.CreateScopeAsync(actor, UserTaskQueryScopeKind.All));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Policy_WildcardPermissionGrantSatisfiesManagementChecks()
|
|
{
|
|
var root = _fixture.Actor("root", "*") with { IsManager = true };
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject);
|
|
|
|
Assert.True(await _fixture.Policy.AuthorizeAsync(task, root, UserTaskAccessOperation.Manage));
|
|
var scope = await _fixture.Policy.CreateScopeAsync(root, UserTaskQueryScopeKind.All);
|
|
Assert.NotNull(scope);
|
|
Assert.True(scope.IsManager);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(UserTaskQueryScopeKind.All)]
|
|
[InlineData(UserTaskQueryScopeKind.NeedsAttention)]
|
|
public async Task Query_ManagerOnlyScopesAreDeniedRatherThanSilentlyNarrowed(UserTaskQueryScopeKind kind)
|
|
{
|
|
var actor = _fixture.Actor("user-1");
|
|
await _fixture.ProjectAsync(actor.Subject);
|
|
|
|
// A denial must be distinguishable from an empty page, or the endpoint would answer 200 with no rows.
|
|
Assert.Null(await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, kind, actor));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Query_AvailableScopeExcludesClaimedTasksAndExcludedCandidates()
|
|
{
|
|
var candidate = _fixture.Actor("user-1");
|
|
var excluded = _fixture.Actor("user-2");
|
|
var open = await _fixture.ProjectAsync(candidate.Subject, definition => definition with
|
|
{
|
|
CandidateUsers = [candidate.Subject, excluded.Subject],
|
|
ExcludedUsers = [excluded.Subject]
|
|
});
|
|
|
|
var availableToCandidate = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Available, candidate);
|
|
Assert.NotNull(availableToCandidate);
|
|
Assert.Single(availableToCandidate.Items);
|
|
|
|
var availableToExcluded = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Available, excluded);
|
|
Assert.NotNull(availableToExcluded);
|
|
Assert.Empty(availableToExcluded.Items);
|
|
|
|
await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, open.Id, new(1, "claim-1"), candidate);
|
|
|
|
var afterClaim = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Available, candidate);
|
|
Assert.NotNull(afterClaim);
|
|
Assert.Empty(afterClaim.Items);
|
|
|
|
var assigned = await _fixture.Manager.QueryAsync(new() { TenantId = UserTaskTestFixture.TenantId }, UserTaskQueryScopeKind.Assigned, candidate);
|
|
Assert.NotNull(assigned);
|
|
Assert.Single(assigned.Items);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Summary_DoesNotDiscloseCandidateIdentitiesOrBlockingHealthToParticipants()
|
|
{
|
|
var candidate = _fixture.Actor("user-1");
|
|
var peer = _fixture.Actor("user-2");
|
|
var task = await _fixture.ProjectAsync(candidate.Subject, definition => definition with { CandidateUsers = [candidate.Subject, peer.Subject] });
|
|
task.HealthSeverity = UserTaskHealthSeverity.Advisory;
|
|
task.HealthCode = "advisory-code";
|
|
|
|
var summary = await UserTaskModelMapper.ToSummaryAsync(task, candidate, _fixture.Policy);
|
|
|
|
Assert.Equal("2 users", summary.CandidateSummary);
|
|
Assert.DoesNotContain(peer.Subject.Id, JsonSerializer.Serialize(summary));
|
|
Assert.Null(summary.HealthSeverity);
|
|
Assert.Null(summary.HealthCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Summary_CarriesWorkflowContextForAuthorizedParticipants()
|
|
{
|
|
var actor = _fixture.Actor("user-1");
|
|
var task = await _fixture.ProjectAsync(actor.Subject);
|
|
|
|
var summary = await UserTaskModelMapper.ToSummaryAsync(task, actor, _fixture.Policy);
|
|
|
|
Assert.Equal("Approval workflow", summary.WorkflowDefinitionName);
|
|
Assert.Equal(3, summary.WorkflowDefinitionVersion);
|
|
Assert.Equal("correlation-1", summary.WorkflowInstanceReference);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Events_AreWithheldFromParticipantsWithoutProtectedAccess()
|
|
{
|
|
var candidate = _fixture.Actor("user-1");
|
|
var task = await _fixture.ProjectAsync(candidate.Subject);
|
|
|
|
var beforeClaim = await _fixture.Manager.GetEventsAsync(UserTaskTestFixture.TenantId, task.Id, null, 50, candidate);
|
|
Assert.NotNull(beforeClaim);
|
|
Assert.Empty(beforeClaim.Items);
|
|
|
|
await _fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), candidate);
|
|
|
|
var afterClaim = await _fixture.Manager.GetEventsAsync(UserTaskTestFixture.TenantId, task.Id, null, 50, candidate);
|
|
Assert.NotNull(afterClaim);
|
|
Assert.NotEmpty(afterClaim.Items);
|
|
// Actor identifiers never reach the audit projection; only a display name may.
|
|
Assert.DoesNotContain(candidate.Subject.Id, JsonSerializer.Serialize(afterClaim));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Detail_WithholdsMaskedFieldValuesUntilAnExplicitRevealAndDoesNotConsumeTheRevision()
|
|
{
|
|
var form = new TestFormProvider(
|
|
new("note", "Note"),
|
|
new("iban", "IBAN", Masked: true, CanReveal: true),
|
|
new("pin", "PIN", Masked: true));
|
|
var fixture = new UserTaskTestFixture(formProviders: form);
|
|
var actor = fixture.Actor("user-1");
|
|
var payload = JsonDocument.Parse("""{"note":"visible","iban":"NL00BANK","pin":"1234"}""").RootElement;
|
|
var task = await fixture.ProjectAsync(actor.Subject, definition => definition with
|
|
{
|
|
FormReference = new("test", "invoice"),
|
|
TaskData = payload
|
|
});
|
|
await fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), actor);
|
|
|
|
var detail = await fixture.Manager.GetAsync(UserTaskTestFixture.TenantId, task.Id, actor);
|
|
var fields = detail!.Form!.Fields.ToDictionary(x => x.Key);
|
|
Assert.Equal("visible", fields["note"].Value?.GetString());
|
|
Assert.Null(fields["iban"].Value);
|
|
Assert.Null(fields["pin"].Value);
|
|
Assert.True(fields["iban"].CanReveal);
|
|
Assert.False(fields["pin"].CanReveal);
|
|
Assert.DoesNotContain("NL00BANK", JsonSerializer.Serialize(detail.Form));
|
|
|
|
var revisionBeforeReveal = detail.Revision;
|
|
var revealed = await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "iban", actor);
|
|
Assert.Equal("NL00BANK", revealed?.GetString());
|
|
|
|
// A field the provider did not mark revealable is indistinguishable from an unknown one.
|
|
Assert.Null(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "pin", actor));
|
|
Assert.Null(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "nope", actor));
|
|
|
|
var afterReveal = await fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id);
|
|
// The reveal is audited but must not consume the concurrency token, or the caller's next command
|
|
// would conflict for no reason.
|
|
Assert.Equal(revisionBeforeReveal, afterReveal!.Revision);
|
|
Assert.Contains(afterReveal.Events, x => x.EventType == "FieldRevealed");
|
|
Assert.DoesNotContain("NL00BANK", JsonSerializer.Serialize(afterReveal.Events));
|
|
|
|
var completion = await fixture.Manager.CompleteAsync(UserTaskTestFixture.TenantId, task.Id,
|
|
new(revisionBeforeReveal, "complete-1", "Approve", payload), actor);
|
|
Assert.True(completion.Accepted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevealField_IsRefusedForCallersWithoutProtectedAccess()
|
|
{
|
|
var form = new TestFormProvider(new UserTaskFormFieldDescriptor("iban", "IBAN", Masked: true, CanReveal: true));
|
|
var fixture = new UserTaskTestFixture(formProviders: form);
|
|
var candidate = fixture.Actor("user-1");
|
|
var task = await fixture.ProjectAsync(candidate.Subject, definition => definition with
|
|
{
|
|
FormReference = new("test", "invoice"),
|
|
TaskData = JsonDocument.Parse("""{"iban":"NL00BANK"}""").RootElement
|
|
});
|
|
|
|
// The candidate can see the task but has not claimed it, so protected access is not granted yet.
|
|
Assert.Null(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "iban", candidate));
|
|
|
|
await fixture.Manager.ClaimAsync(UserTaskTestFixture.TenantId, task.Id, new(1, "claim-1"), candidate);
|
|
Assert.NotNull(await fixture.Manager.RevealFieldAsync(UserTaskTestFixture.TenantId, task.Id, "iban", candidate));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DueService_ReservesTimeoutAndResumesWorkflow()
|
|
{
|
|
var dueAt = _fixture.Clock.UtcNow.AddMinutes(-1);
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject,
|
|
definition => definition with { DueAt = dueAt, EnableTimeoutOutcome = true });
|
|
var due = new DefaultUserTaskDueService(_fixture.Repository, _fixture.Manager, _fixture.Sink, _fixture.Identity, _fixture.Clock);
|
|
|
|
Assert.Equal(1, await due.MarkOverdueAsync(UserTaskTestFixture.TenantId, _fixture.Clock.UtcNow));
|
|
var timingOut = await _fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id);
|
|
Assert.Equal(UserTaskStatus.TimingOut, timingOut!.Status);
|
|
Assert.Equal("Timeout", _fixture.Resumer.LastStimulus!.ActionKey);
|
|
|
|
await _fixture.Projection.FinalizeBookmarkRemovalAsync(new(UserTaskTestFixture.TenantId, task.Id, task.BookmarkId, _fixture.Clock.UtcNow));
|
|
var timedOut = await _fixture.Repository.GetAsync(UserTaskTestFixture.TenantId, task.Id);
|
|
Assert.Equal(UserTaskStatus.TimedOut, timedOut!.Status);
|
|
}
|
|
}
|