< Summary

Information
Class: Elsa.UserTasks.Repositories.InMemoryUserTaskRepository
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs
Line coverage
93%
Covered lines: 204
Uncovered lines: 14
Coverable lines: 218
Total lines: 333
Line coverage: 93.5%
Branch coverage
77%
Covered branches: 193
Total branches: 250
Branch coverage: 77.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor()100%11100%
GetAsync(...)100%22100%
QueryAsync(...)61.76%3434100%
FindByMaterializationKeyAsync(...)100%22100%
FindByBookmarkIdAsync(...)100%22100%
FindByInvitationTokenHashAsync(...)100%66100%
SaveAsync(...)100%44100%
AddProjectionAsync(...)87.5%88100%
AppendEventAsync(...)100%22100%
TryMutateAsync(...)100%66100%
IsVisible(...)40%1013057.14%
IsEligible(...)100%66100%
NeedsAttention(...)0%110100%
MatchesSearch(...)55.55%1818100%
ApplyOrdering(...)100%1818100%
ApplyCursor(...)94.28%777088.88%
TitleIsAfterCursor(...)100%1010100%
CreateCursor(...)100%1212100%
TryReadCursor(...)50%9873.33%
Key(...)100%11100%
Clone(...)100%11100%
Clone(...)100%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Repositories/InMemoryUserTaskRepository.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Serialization;
 3using Elsa.UserTasks.Contracts;
 4using Elsa.UserTasks.Models;
 5
 6namespace Elsa.UserTasks.Repositories;
 7
 8/// <summary>
 9/// A deterministic repository for the Core module and development hosts. It uses a single lock to
 10/// provide the same compare-and-swap and projection idempotency guarantees expected from durable stores.
 11/// </summary>
 12public sealed class InMemoryUserTaskRepository : IUserTaskRepository
 13{
 214    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
 215    {
 216        Converters = { new JsonStringEnumConverter() }
 217    };
 18
 19    /// <summary>
 20    /// Title OrderBy, ties, and cursors share this comparer. Title cursors are not portable to EF
 21    /// (column collation) or across databases; recreate the list after a provider change.
 22    /// </summary>
 223    private static readonly StringComparer TitleComparer = StringComparer.Ordinal;
 24
 9325    private readonly object _sync = new();
 9326    private readonly Dictionary<string, UserTask> _tasks = new(StringComparer.Ordinal);
 27
 28    public Task<UserTask?> GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
 29    {
 24630        lock (_sync)
 24631            return Task.FromResult(_tasks.TryGetValue(Key(tenantId, taskId), out var task) ? Clone(task) : null);
 24632    }
 33
 34    public Task<UserTaskQueryResult> QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default)
 35    {
 17436        lock (_sync)
 37        {
 17438            var scope = query.Scope;
 17439            var items = _tasks.Values
 817640                .Where(x => string.Equals(x.TenantId, query.TenantId, StringComparison.Ordinal))
 17441                // Visibility is evaluated before counting, cursoring, and paging so an unauthorized row can
 17442                // never influence a total or push an authorized row off the page.
 96343                .Where(x => scope == null || IsVisible(x, scope))
 94344                .Where(x => query.Statuses.Count == 0 || query.Statuses.Contains(x.Status))
 94345                .Where(x => !query.OnlyOverdue || x.IsOverdue)
 94346                .Where(x => !query.OnlyWithoutDueDate || x.DueAt == null)
 94347                .Where(x => query.PriorityFrom == null || x.Priority >= query.PriorityFrom)
 94348                .Where(x => query.PriorityTo == null || x.Priority <= query.PriorityTo)
 94349                .Where(x => query.DueFrom == null || x.DueAt >= query.DueFrom)
 94350                .Where(x => query.DueTo == null || x.DueAt <= query.DueTo)
 94351                .Where(x => query.WorkflowDefinitionId == null || x.WorkflowDefinitionId == query.WorkflowDefinitionId)
 94352                .Where(x => query.WorkflowInstanceId == null || x.WorkflowInstanceId == query.WorkflowInstanceId)
 94353                .Where(x => query.Reference == null || string.Equals(x.Reference, query.Reference, StringComparison.Ordi
 94354                .Where(x => query.TaskType == null || string.Equals(x.TaskType, query.TaskType, StringComparison.Ordinal
 111755                .Where(x => MatchesSearch(x, query.Search));
 56
 17457            var filteredCount = query.IncludeTotalCount ? items.Count() : 0;
 17458            var materialized = ApplyOrdering(items, query).ToList();
 17459            materialized = ApplyCursor(materialized, query).ToList();
 60
 17461            int? total = query.IncludeTotalCount ? filteredCount : null;
 17462            var limit = Math.Clamp(query.Limit, 1, 200);
 17463            var page = materialized.Take(limit).Select(Clone).ToArray();
 17464            var next = materialized.Count > limit ? CreateCursor(page[^1], query.Sort) : null;
 17465            return Task.FromResult(new UserTaskQueryResult(page, next, total));
 66        }
 17467    }
 68
 69    public Task<UserTask?> FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToke
 70    {
 8871        lock (_sync)
 30772            return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.MaterializationKey == k
 8873    }
 74
 75    public Task<UserTask?> FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken
 76    {
 277        lock (_sync)
 17678            return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.BookmarkId == bookmarkI
 279    }
 80
 81    public Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, Cancel
 82    {
 3583        lock (_sync)
 84        {
 46985            foreach (var task in _tasks.Values)
 86            {
 87                // Ordinal comparison over a fixed-length hex hash; the secret itself is never stored.
 26388                var invitation = task.Invitations.FirstOrDefault(x => string.Equals(x.TokenHash, tokenHash, StringCompar
 21589                if (invitation != null)
 3190                    return Task.FromResult<(UserTask, UserTaskInvitation)?>((Clone(task), invitation));
 91            }
 492            return Task.FromResult<(UserTask, UserTaskInvitation)?>(null);
 93        }
 3594    }
 95
 96    public Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default)
 97    {
 5598        lock (_sync)
 99        {
 55100            var key = Key(task.TenantId, task.Id);
 55101            if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision)
 2102                throw new UserTaskRevisionConflictException(task.Id, expectedRevision);
 103
 53104            var copy = Clone(task);
 53105            copy.Revision = expectedRevision + 1;
 53106            copy.UpdatedAt = DateTimeOffset.UtcNow;
 53107            _tasks[key] = copy;
 53108            return Task.CompletedTask;
 109        }
 53110    }
 111
 112    public Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default)
 113    {
 200114        lock (_sync)
 115        {
 6803116            if (_tasks.Values.Any(x => x.TenantId == task.TenantId &&
 6803117                ((!string.IsNullOrEmpty(task.MaterializationKey) && x.MaterializationKey == task.MaterializationKey) ||
 6803118                 (!string.IsNullOrEmpty(task.BookmarkId) && x.BookmarkId == task.BookmarkId))))
 1119                return Task.CompletedTask;
 120
 199121            _tasks[Key(task.TenantId, task.Id)] = Clone(task);
 199122            return Task.CompletedTask;
 123        }
 200124    }
 125
 126    public Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationTok
 127    {
 5128        lock (_sync)
 129        {
 5130            if (_tasks.TryGetValue(Key(tenantId, taskId), out var current))
 4131                current.Events.Add(@event);
 5132            return Task.CompletedTask;
 133        }
 5134    }
 135
 136    public Task<bool> TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func<UserTask, bool> mutation
 137    {
 39138        lock (_sync)
 139        {
 39140            var key = Key(tenantId, taskId);
 39141            if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision)
 1142                return Task.FromResult(false);
 143
 38144            var copy = Clone(current);
 38145            if (!mutation(copy))
 1146                return Task.FromResult(false);
 147
 37148            copy.Revision = expectedRevision + 1;
 37149            copy.UpdatedAt = DateTimeOffset.UtcNow;
 37150            _tasks[key] = copy;
 37151            return Task.FromResult(true);
 152        }
 39153    }
 154
 155    private static bool IsVisible(UserTask task, UserTaskQueryScope scope)
 156    {
 816157        if (!string.Equals(task.TenantId, scope.TenantId, StringComparison.Ordinal))
 2158            return false;
 814159        if (scope.ExcludeBlocking && task.HealthSeverity == UserTaskHealthSeverity.Blocking)
 0160            return false;
 161        // Manager-only scopes were already rejected by the policy for non-managers, so reaching them here
 162        // means the caller manages the tenant.
 814163        if (scope.RequiresManager)
 0164            return scope.IsManager && (scope.Kind != UserTaskQueryScopeKind.NeedsAttention || NeedsAttention(task));
 165
 814166        return scope.Kind switch
 814167        {
 1168            UserTaskQueryScopeKind.Assigned => task.Assignee?.Matches(scope.Subject) == true,
 813169            UserTaskQueryScopeKind.Available => task.IsOpen && task.Assignee == null && IsEligible(task, scope),
 0170            UserTaskQueryScopeKind.History => task.IsTerminal &&
 0171                                              (task.CompletedBy?.Matches(scope.Subject) == true || task.Events.Any(x => 
 0172            _ => false
 814173        };
 174    }
 175
 176    private static bool IsEligible(UserTask task, UserTaskQueryScope scope)
 177    {
 820178        if (task.ExcludedUsers.Any(x => x.Matches(scope.Subject)))
 7179            return false;
 805180        if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot)
 10181            return task.SnapshotMembers.Any(x => x.Matches(scope.Subject));
 1598182        return task.CandidateUsers.Any(x => x.Matches(scope.Subject))
 799183               || task.CandidateGroups.Any(candidate => scope.Groups.Any(candidate.Matches));
 184    }
 185
 186    private static bool NeedsAttention(UserTask task) =>
 0187        task.HealthSeverity == UserTaskHealthSeverity.Blocking
 0188        || task.IsOverdue
 0189        || (task.IsOpen && task.Assignee == null)
 0190        || task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling;
 191
 192    private static bool MatchesSearch(UserTask task, string? search)
 193    {
 943194        if (string.IsNullOrWhiteSpace(search))
 925195            return true;
 18196        var value = search.Trim();
 18197        return (task.Title?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 18198               || (task.Summary?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 18199               || (task.Reference?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 18200               || (task.TaskType?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 40201               || task.Tags.Any(x => x.Contains(value, StringComparison.OrdinalIgnoreCase));
 202    }
 203
 204    // Same contract as EF/VNext: REST sorts only, Id always ThenBy ascending, JSON base64url cursors.
 174205    private static IEnumerable<UserTask> ApplyOrdering(IEnumerable<UserTask> tasks, UserTaskQuery query) => query.Sort.T
 174206    {
 345207        "priority" => query.Descending ? tasks.OrderByDescending(x => x.Priority).ThenBy(x => x.Id) : tasks.OrderBy(x =>
 360208        "title" => query.Descending ? tasks.OrderByDescending(x => x.Title, TitleComparer).ThenBy(x => x.Id) : tasks.Ord
 535209        "due" => query.Descending ? tasks.OrderBy(x => x.DueAt == null).ThenByDescending(x => x.DueAt).ThenBy(x => x.Id)
 366210        "updated" => query.Descending ? tasks.OrderByDescending(x => x.UpdatedAt).ThenBy(x => x.Id) : tasks.OrderBy(x =>
 432211        _ => query.Descending ? tasks.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id) : tasks.OrderBy(x => x.Creat
 174212    };
 213
 214    private static IEnumerable<UserTask> ApplyCursor(IEnumerable<UserTask> tasks, UserTaskQuery query)
 215    {
 174216        if (string.IsNullOrWhiteSpace(query.Cursor) || !TryReadCursor(query.Cursor, out var value, out var id))
 80217            return tasks;
 218        return query.Sort.ToLowerInvariant() switch
 219        {
 138220            "priority" when int.TryParse(value, out var priority) => tasks.Where(x => query.Descending ? x.Priority < pr
 126221            "title" => tasks.Where(x => TitleIsAfterCursor(x.Title, value, x.Id, id, query.Descending)),
 32222            "due" when value == "~null" => tasks.Where(x => x.DueAt == null && string.Compare(x.Id, id) > 0),
 122223            "due" when DateTimeOffset.TryParse(value, out var due) => tasks.Where(x => x.DueAt == null || query.Descendi
 138224            "updated" when DateTimeOffset.TryParse(value, out var updated) => tasks.Where(x => query.Descending ? x.Upda
 147225            _ when DateTimeOffset.TryParse(value, out var created) => tasks.Where(x => query.Descending ? x.CreatedAt < 
 0226            _ => tasks
 227        };
 228    }
 229
 230    private static bool TitleIsAfterCursor(string title, string cursorTitle, string id, string cursorId, bool descending
 231    {
 106232        var comparison = TitleComparer.Compare(title, cursorTitle);
 106233        return descending
 106234            ? comparison < 0 || comparison == 0 && string.Compare(id, cursorId) > 0
 106235            : comparison > 0 || comparison == 0 && string.Compare(id, cursorId) > 0;
 236    }
 237
 238    private static string CreateCursor(UserTask task, string sort)
 239    {
 95240        var value = sort.ToLowerInvariant() switch
 95241        {
 18242            "priority" => task.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
 20243            "title" => task.Title,
 18244            "due" => task.DueAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? "~null",
 18245            "updated" => task.UpdatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
 21246            _ => task.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
 95247        };
 95248        return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(new[] { value, task.Id }, JsonOptions)).TrimEn
 249    }
 250
 251    private static bool TryReadCursor(string? cursor, out string value, out string id)
 252    {
 95253        value = id = "";
 95254        if (string.IsNullOrWhiteSpace(cursor))
 0255            return false;
 256        try
 257        {
 95258            var padded = cursor.Replace('-', '+').Replace('_', '/') + new string('=', (4 - cursor.Length % 4) % 4);
 95259            var values = JsonSerializer.Deserialize<string[]>(Convert.FromBase64String(padded), JsonOptions);
 94260            if (values is not [var parsedValue, var parsedId] || string.IsNullOrWhiteSpace(parsedId))
 0261                return false;
 94262            value = parsedValue;
 94263            id = parsedId;
 94264            return true;
 265        }
 0266        catch (FormatException)
 267        {
 0268            return false;
 269        }
 1270        catch (JsonException)
 271        {
 1272            return false;
 273        }
 95274    }
 275
 544276    private static string Key(string tenantId, string taskId) => tenantId + "\0" + taskId;
 277
 943278    private static UserTask Clone(UserTask task) => new()
 943279    {
 943280        Id = task.Id,
 943281        TenantId = task.TenantId,
 943282        WorkflowDefinitionId = task.WorkflowDefinitionId,
 943283        WorkflowDefinitionName = task.WorkflowDefinitionName,
 943284        WorkflowDefinitionVersion = task.WorkflowDefinitionVersion,
 943285        WorkflowInstanceId = task.WorkflowInstanceId,
 943286        WorkflowInstanceReference = task.WorkflowInstanceReference,
 943287        ActivityInstanceId = task.ActivityInstanceId,
 943288        BookmarkId = task.BookmarkId,
 943289        MaterializationKey = task.MaterializationKey,
 943290        Title = task.Title,
 943291        Summary = task.Summary,
 943292        Reference = task.Reference,
 943293        Tags = new HashSet<string>(task.Tags, StringComparer.OrdinalIgnoreCase),
 943294        TaskType = task.TaskType,
 943295        Requester = task.Requester,
 943296        Assignee = task.Assignee,
 943297        CandidateUsers = [..task.CandidateUsers],
 943298        CandidateGroups = [..task.CandidateGroups],
 943299        SnapshotMembers = [..task.SnapshotMembers],
 943300        SnapshotGroups = [..task.SnapshotGroups],
 943301        ExcludedUsers = [..task.ExcludedUsers],
 943302        MembershipResolutionMode = task.MembershipResolutionMode,
 943303        AllowManagerExclusionOverride = task.AllowManagerExclusionOverride,
 943304        Priority = task.Priority,
 943305        DueAt = task.DueAt,
 943306        IsOverdue = task.IsOverdue,
 943307        Instructions = task.Instructions,
 943308        TaskData = Clone(task.TaskData),
 943309        RequestedForm = task.RequestedForm,
 943310        PinnedForm = task.PinnedForm,
 943311        Actions = [..task.Actions],
 943312        InvitationDefinitions = [..task.InvitationDefinitions],
 943313        EnableTimeoutOutcome = task.EnableTimeoutOutcome,
 943314        EnableCancellationOutcome = task.EnableCancellationOutcome,
 943315        Status = task.Status,
 943316        HealthSeverity = task.HealthSeverity,
 943317        HealthCode = task.HealthCode,
 943318        HealthMessage = task.HealthMessage,
 943319        CompletionActionKey = task.CompletionActionKey,
 943320        CompletionData = Clone(task.CompletionData),
 943321        CompletedBy = task.CompletedBy,
 943322        CreatedAt = task.CreatedAt,
 943323        UpdatedAt = task.UpdatedAt,
 943324        AssignedAt = task.AssignedAt,
 943325        CompletedAt = task.CompletedAt,
 943326        Revision = task.Revision,
 943327        Events = [..task.Events],
 943328        Operations = [..task.Operations],
 943329        Invitations = [..task.Invitations]
 943330    };
 331
 1886332    private static JsonElement? Clone(JsonElement? value) => value is { } element ? element.Clone() : null;
 333}

Methods/Properties

.cctor()
.ctor()
GetAsync(System.String,System.String,System.Threading.CancellationToken)
QueryAsync(Elsa.UserTasks.Models.UserTaskQuery,System.Threading.CancellationToken)
FindByMaterializationKeyAsync(System.String,System.String,System.Threading.CancellationToken)
FindByBookmarkIdAsync(System.String,System.String,System.Threading.CancellationToken)
FindByInvitationTokenHashAsync(System.String,System.Threading.CancellationToken)
SaveAsync(Elsa.UserTasks.Models.UserTask,System.Int32,System.Threading.CancellationToken)
AddProjectionAsync(Elsa.UserTasks.Models.UserTask,System.Threading.CancellationToken)
AppendEventAsync(System.String,System.String,Elsa.UserTasks.Models.UserTaskEvent,System.Threading.CancellationToken)
TryMutateAsync(System.String,System.String,System.Int32,System.Func`2<Elsa.UserTasks.Models.UserTask,System.Boolean>,System.Threading.CancellationToken)
IsVisible(Elsa.UserTasks.Models.UserTask,Elsa.UserTasks.Models.UserTaskQueryScope)
IsEligible(Elsa.UserTasks.Models.UserTask,Elsa.UserTasks.Models.UserTaskQueryScope)
NeedsAttention(Elsa.UserTasks.Models.UserTask)
MatchesSearch(Elsa.UserTasks.Models.UserTask,System.String)
ApplyOrdering(System.Collections.Generic.IEnumerable`1<Elsa.UserTasks.Models.UserTask>,Elsa.UserTasks.Models.UserTaskQuery)
ApplyCursor(System.Collections.Generic.IEnumerable`1<Elsa.UserTasks.Models.UserTask>,Elsa.UserTasks.Models.UserTaskQuery)
TitleIsAfterCursor(System.String,System.String,System.String,System.String,System.Boolean)
CreateCursor(Elsa.UserTasks.Models.UserTask,System.String)
TryReadCursor(System.String,System.String&,System.String&)
Key(System.String,System.String)
Clone(Elsa.UserTasks.Models.UserTask)
Clone(System.Nullable`1<System.Text.Json.JsonElement>)