< 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
89%
Covered lines: 224
Uncovered lines: 27
Coverable lines: 251
Total lines: 367
Line coverage: 89.2%
Branch coverage
68%
Covered branches: 162
Total branches: 236
Branch coverage: 68.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
GetAsync(...)100%22100%
QueryAsync(...)65.78%3838100%
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(...)83.33%6683.33%
NeedsAttention(...)0%110100%
MatchesSearch(...)5.55%1551825%
Sort(...)72.72%322272.72%
IsAfterCursor(...)100%1414100%
EncodeCursor(...)100%11100%
DecodeCursor(...)50%44100%
NormalizeSort(...)87.5%251666.66%
SortKind(...)100%88100%
SortValue(...)80%111077.77%
EncodeSortValue(...)87.5%88100%
CompareSortValue(...)88.88%181890.9%
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;
 2using System.Text.Json;
 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{
 8114    private readonly object _sync = new();
 8115    private readonly Dictionary<string, UserTask> _tasks = new(StringComparer.Ordinal);
 16
 17    public Task<UserTask?> GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
 18    {
 24619        lock (_sync)
 24620            return Task.FromResult(_tasks.TryGetValue(Key(tenantId, taskId), out var task) ? Clone(task) : null);
 24621    }
 22
 23    public Task<UserTaskQueryResult> QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default)
 24    {
 12925        lock (_sync)
 26        {
 12927            var scope = query.Scope;
 12928            var items = _tasks.Values
 454729                .Where(x => string.Equals(x.TenantId, query.TenantId, StringComparison.Ordinal))
 12930                // Visibility is evaluated before counting, cursoring, and paging so an unauthorized row can
 12931                // never influence a total or push an authorized row off the page.
 73932                .Where(x => scope == null || IsVisible(x, scope))
 72733                .Where(x => query.Statuses.Count == 0 || query.Statuses.Contains(x.Status))
 72734                .Where(x => !query.OnlyOverdue || x.IsOverdue)
 72735                .Where(x => !query.OnlyWithoutDueDate || x.DueAt == null)
 72736                .Where(x => query.PriorityFrom == null || x.Priority >= query.PriorityFrom)
 72737                .Where(x => query.PriorityTo == null || x.Priority <= query.PriorityTo)
 72738                .Where(x => query.DueFrom == null || x.DueAt >= query.DueFrom)
 72739                .Where(x => query.DueTo == null || x.DueAt <= query.DueTo)
 72740                .Where(x => query.WorkflowDefinitionId == null || x.WorkflowDefinitionId == query.WorkflowDefinitionId)
 72741                .Where(x => query.WorkflowInstanceId == null || x.WorkflowInstanceId == query.WorkflowInstanceId)
 72742                .Where(x => query.Reference == null || string.Equals(x.Reference, query.Reference, StringComparison.Ordi
 72743                .Where(x => query.TaskType == null || string.Equals(x.TaskType, query.TaskType, StringComparison.Ordinal
 85644                .Where(x => MatchesSearch(x, query.Search));
 45
 12946            var filteredCount = query.IncludeTotalCount ? items.Count() : 0;
 12947            var materialized = Sort(items, query.Sort, query.Descending).ToList();
 12948            if (!string.IsNullOrWhiteSpace(query.Cursor) && DecodeCursor(query.Cursor!) is { } cursor)
 48749                materialized = materialized.Where(x => IsAfterCursor(x, cursor, query.Descending, query.Sort)).ToList();
 50
 12951            int? total = query.IncludeTotalCount ? filteredCount : null;
 12952            var limit = Math.Clamp(query.Limit, 1, 200);
 12953            var page = materialized.Take(limit).Select(Clone).ToArray();
 12954            var next = materialized.Count > limit ? EncodeCursor(page[^1], query.Sort) : null;
 12955            return Task.FromResult(new UserTaskQueryResult(page, next, total));
 56        }
 12957    }
 58
 59    public Task<UserTask?> FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToke
 60    {
 8861        lock (_sync)
 27162            return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.MaterializationKey == k
 8863    }
 64
 65    public Task<UserTask?> FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken
 66    {
 267        lock (_sync)
 14068            return Task.FromResult(_tasks.Values.FirstOrDefault(x => x.TenantId == tenantId && x.BookmarkId == bookmarkI
 269    }
 70
 71    public Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, Cancel
 72    {
 3573        lock (_sync)
 74        {
 39775            foreach (var task in _tasks.Values)
 76            {
 77                // Ordinal comparison over a fixed-length hex hash; the secret itself is never stored.
 22778                var invitation = task.Invitations.FirstOrDefault(x => string.Equals(x.TokenHash, tokenHash, StringCompar
 17979                if (invitation != null)
 3180                    return Task.FromResult<(UserTask, UserTaskInvitation)?>((Clone(task), invitation));
 81            }
 482            return Task.FromResult<(UserTask, UserTaskInvitation)?>(null);
 83        }
 3584    }
 85
 86    public Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default)
 87    {
 5588        lock (_sync)
 89        {
 5590            var key = Key(task.TenantId, task.Id);
 5591            if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision)
 292                throw new UserTaskRevisionConflictException(task.Id, expectedRevision);
 93
 5394            var copy = Clone(task);
 5395            copy.Revision = expectedRevision + 1;
 5396            copy.UpdatedAt = DateTimeOffset.UtcNow;
 5397            _tasks[key] = copy;
 5398            return Task.CompletedTask;
 99        }
 53100    }
 101
 102    public Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default)
 103    {
 159104        lock (_sync)
 105        {
 4025106            if (_tasks.Values.Any(x => x.TenantId == task.TenantId &&
 4025107                ((!string.IsNullOrEmpty(task.MaterializationKey) && x.MaterializationKey == task.MaterializationKey) ||
 4025108                 (!string.IsNullOrEmpty(task.BookmarkId) && x.BookmarkId == task.BookmarkId))))
 1109                return Task.CompletedTask;
 110
 158111            _tasks[Key(task.TenantId, task.Id)] = Clone(task);
 158112            return Task.CompletedTask;
 113        }
 159114    }
 115
 116    public Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationTok
 117    {
 5118        lock (_sync)
 119        {
 5120            if (_tasks.TryGetValue(Key(tenantId, taskId), out var current))
 4121                current.Events.Add(@event);
 5122            return Task.CompletedTask;
 123        }
 5124    }
 125
 126    public Task<bool> TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func<UserTask, bool> mutation
 127    {
 39128        lock (_sync)
 129        {
 39130            var key = Key(tenantId, taskId);
 39131            if (!_tasks.TryGetValue(key, out var current) || current.Revision != expectedRevision)
 1132                return Task.FromResult(false);
 133
 38134            var copy = Clone(current);
 38135            if (!mutation(copy))
 1136                return Task.FromResult(false);
 137
 37138            copy.Revision = expectedRevision + 1;
 37139            copy.UpdatedAt = DateTimeOffset.UtcNow;
 37140            _tasks[key] = copy;
 37141            return Task.FromResult(true);
 142        }
 39143    }
 144
 145    private static bool IsVisible(UserTask task, UserTaskQueryScope scope)
 146    {
 630147        if (!string.Equals(task.TenantId, scope.TenantId, StringComparison.Ordinal))
 2148            return false;
 628149        if (scope.ExcludeBlocking && task.HealthSeverity == UserTaskHealthSeverity.Blocking)
 0150            return false;
 151        // Manager-only scopes were already rejected by the policy for non-managers, so reaching them here
 152        // means the caller manages the tenant.
 628153        if (scope.RequiresManager)
 0154            return scope.IsManager && (scope.Kind != UserTaskQueryScopeKind.NeedsAttention || NeedsAttention(task));
 155
 628156        return scope.Kind switch
 628157        {
 1158            UserTaskQueryScopeKind.Assigned => task.Assignee?.Matches(scope.Subject) == true,
 627159            UserTaskQueryScopeKind.Available => task.IsOpen && task.Assignee == null && IsEligible(task, scope),
 0160            UserTaskQueryScopeKind.History => task.IsTerminal &&
 0161                                              (task.CompletedBy?.Matches(scope.Subject) == true || task.Events.Any(x => 
 0162            _ => false
 628163        };
 164    }
 165
 166    private static bool IsEligible(UserTask task, UserTaskQueryScope scope)
 167    {
 632168        if (task.ExcludedUsers.Any(x => x.Matches(scope.Subject)))
 5169            return false;
 621170        if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot)
 0171            return task.SnapshotMembers.Any(x => x.Matches(scope.Subject));
 1242172        return task.CandidateUsers.Any(x => x.Matches(scope.Subject))
 621173               || task.CandidateGroups.Any(candidate => scope.Groups.Any(candidate.Matches));
 174    }
 175
 176    private static bool NeedsAttention(UserTask task) =>
 0177        task.HealthSeverity == UserTaskHealthSeverity.Blocking
 0178        || task.IsOverdue
 0179        || (task.IsOpen && task.Assignee == null)
 0180        || task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling;
 181
 182    private static bool MatchesSearch(UserTask task, string? search)
 183    {
 727184        if (string.IsNullOrWhiteSpace(search))
 727185            return true;
 0186        var value = search.Trim();
 0187        return (task.Title?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 0188               || (task.Summary?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 0189               || (task.Reference?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 0190               || (task.TaskType?.Contains(value, StringComparison.OrdinalIgnoreCase) == true)
 0191               || task.Tags.Any(x => x.Contains(value, StringComparison.OrdinalIgnoreCase));
 192    }
 193
 194    private static IEnumerable<UserTask> Sort(IEnumerable<UserTask> items, string sort, bool descending)
 195    {
 129196        var normalized = NormalizeSort(sort);
 129197        return normalized switch
 129198        {
 31199            "due" => descending
 252200                ? items.OrderByDescending(x => x.DueAt.HasValue).ThenByDescending(x => x.DueAt).ThenByDescending(x => x.
 283201                : items.OrderBy(x => x.DueAt.HasValue ? 0 : 1).ThenBy(x => x.DueAt).ThenBy(x => x.Id, StringComparer.Ord
 28202            "priority" => descending
 156203                ? items.OrderByDescending(x => x.Priority).ThenByDescending(x => x.Id, StringComparer.Ordinal)
 184204                : items.OrderBy(x => x.Priority).ThenBy(x => x.Id, StringComparer.Ordinal),
 0205            "updated" => descending
 0206                ? items.OrderByDescending(x => x.UpdatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal)
 0207                : items.OrderBy(x => x.UpdatedAt).ThenBy(x => x.Id, StringComparer.Ordinal),
 0208            "completed" => descending
 0209                ? items.OrderByDescending(x => x.CompletedAt.HasValue).ThenByDescending(x => x.CompletedAt).ThenByDescen
 0210                : items.OrderBy(x => x.CompletedAt.HasValue ? 0 : 1).ThenBy(x => x.CompletedAt).ThenBy(x => x.Id, String
 28211            "title" => descending
 156212                ? items.OrderByDescending(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenByDescending(x => x.Id, St
 184213                : items.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.Id, StringComparer.Ordinal
 42214            _ => descending
 156215                ? items.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id, StringComparer.Ordinal)
 258216                : items.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id, StringComparer.Ordinal)
 129217        };
 218    }
 219
 220    private static bool IsAfterCursor(UserTask task, (string Kind, string Value, string Id) cursor, bool descending, str
 221    {
 413222        var normalized = NormalizeSort(sort);
 223        // Due/completed ordering keeps null values at the end in both directions. A generic numeric
 224        // comparison would incorrectly drop nulls after a descending non-null page (or reintroduce
 225        // non-null values after a descending null page).
 413226        if (descending && (normalized is "due" or "completed"))
 227        {
 51228            var valueIsNull = SortValue(task, normalized) == null;
 51229            var cursorIsNull = cursor.Value == "~";
 51230            if (valueIsNull != cursorIsNull)
 19231                return valueIsNull;
 232        }
 233
 394234        var comparison = CompareSortValue(SortKind(sort), SortValue(task, sort), cursor.Kind, cursor.Value);
 394235        if (comparison == 0)
 84236            comparison = StringComparer.Ordinal.Compare(task.Id, cursor.Id);
 394237        return descending ? comparison < 0 : comparison > 0;
 238    }
 239
 240    private static string EncodeCursor(UserTask task, string sort)
 241    {
 75242        var kind = SortKind(sort);
 75243        var value = EncodeSortValue(SortValue(task, sort), kind);
 75244        return Convert.ToBase64String(Encoding.UTF8.GetBytes(kind + "|" + value + "|" + task.Id));
 245    }
 246
 247    private static (string Kind, string Value, string Id)? DecodeCursor(string cursor)
 248    {
 249        try
 250        {
 75251            var value = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
 74252            var first = value.IndexOf('|');
 74253            var last = value.LastIndexOf('|');
 74254            return first <= 0 || last <= first ? null : (value[..first], value[(first + 1)..last], value[(last + 1)..]);
 255        }
 1256        catch (FormatException)
 257        {
 1258            return null;
 259        }
 75260    }
 261
 1531262    private static string NormalizeSort(string sort) => sort.ToLowerInvariant() switch
 1531263    {
 0264        "dueat" => "due",
 0265        "completedat" => "completed",
 1531266        _ => sort.ToLowerInvariant() is "due" or "priority" or "updated" or "completed" or "title" ? sort.ToLowerInvaria
 1531267    };
 268
 469269    private static string SortKind(string sort) => NormalizeSort(sort) switch
 469270    {
 120271        "priority" => "i",
 120272        "title" => "s",
 101273        "due" or "completed" => "n",
 128274        _ => "n"
 469275    };
 276
 520277    private static object? SortValue(UserTask task, string sort) => NormalizeSort(sort) switch
 520278    {
 152279        "due" => task.DueAt,
 120280        "priority" => task.Priority,
 0281        "updated" => task.UpdatedAt,
 0282        "completed" => task.CompletedAt,
 120283        "title" => task.Title,
 128284        _ => task.CreatedAt
 520285    };
 286
 75287    private static string EncodeSortValue(object? value, string kind) => value switch
 75288    {
 2289        null => "~",
 37290        DateTimeOffset date => date.UtcTicks.ToString(System.Globalization.CultureInfo.InvariantCulture),
 18291        int number => number.ToString(System.Globalization.CultureInfo.InvariantCulture),
 18292        _ => Convert.ToBase64String(Encoding.UTF8.GetBytes(value.ToString() ?? ""))
 75293    };
 294
 295    private static int CompareSortValue(string kind, object? value, string cursorKind, string cursorValue)
 296    {
 394297        if (!string.Equals(kind, cursorKind, StringComparison.Ordinal))
 0298            return 0;
 394299        if (value == null || cursorValue == "~")
 23300            return value == null && cursorValue == "~" ? 0 : value == null ? 1 : -1;
 371301        if (kind == "i")
 102302            return int.Parse(value.ToString()!, System.Globalization.CultureInfo.InvariantCulture).CompareTo(int.Parse(c
 269303        if (kind == "n")
 334304            return long.Parse(value switch { DateTimeOffset d => d.UtcTicks.ToString(System.Globalization.CultureInfo.In
 167305                .CompareTo(long.Parse(cursorValue, System.Globalization.CultureInfo.InvariantCulture));
 102306        var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursorValue));
 102307        return StringComparer.OrdinalIgnoreCase.Compare(value.ToString(), decoded);
 308    }
 309
 503310    private static string Key(string tenantId, string taskId) => tenantId + "\0" + taskId;
 311
 821312    private static UserTask Clone(UserTask task) => new()
 821313    {
 821314        Id = task.Id,
 821315        TenantId = task.TenantId,
 821316        WorkflowDefinitionId = task.WorkflowDefinitionId,
 821317        WorkflowDefinitionName = task.WorkflowDefinitionName,
 821318        WorkflowDefinitionVersion = task.WorkflowDefinitionVersion,
 821319        WorkflowInstanceId = task.WorkflowInstanceId,
 821320        WorkflowInstanceReference = task.WorkflowInstanceReference,
 821321        ActivityInstanceId = task.ActivityInstanceId,
 821322        BookmarkId = task.BookmarkId,
 821323        MaterializationKey = task.MaterializationKey,
 821324        Title = task.Title,
 821325        Summary = task.Summary,
 821326        Reference = task.Reference,
 821327        Tags = new HashSet<string>(task.Tags, StringComparer.OrdinalIgnoreCase),
 821328        TaskType = task.TaskType,
 821329        Requester = task.Requester,
 821330        Assignee = task.Assignee,
 821331        CandidateUsers = [..task.CandidateUsers],
 821332        CandidateGroups = [..task.CandidateGroups],
 821333        SnapshotMembers = [..task.SnapshotMembers],
 821334        SnapshotGroups = [..task.SnapshotGroups],
 821335        ExcludedUsers = [..task.ExcludedUsers],
 821336        MembershipResolutionMode = task.MembershipResolutionMode,
 821337        AllowManagerExclusionOverride = task.AllowManagerExclusionOverride,
 821338        Priority = task.Priority,
 821339        DueAt = task.DueAt,
 821340        IsOverdue = task.IsOverdue,
 821341        Instructions = task.Instructions,
 821342        TaskData = Clone(task.TaskData),
 821343        RequestedForm = task.RequestedForm,
 821344        PinnedForm = task.PinnedForm,
 821345        Actions = [..task.Actions],
 821346        InvitationDefinitions = [..task.InvitationDefinitions],
 821347        EnableTimeoutOutcome = task.EnableTimeoutOutcome,
 821348        EnableCancellationOutcome = task.EnableCancellationOutcome,
 821349        Status = task.Status,
 821350        HealthSeverity = task.HealthSeverity,
 821351        HealthCode = task.HealthCode,
 821352        HealthMessage = task.HealthMessage,
 821353        CompletionActionKey = task.CompletionActionKey,
 821354        CompletionData = Clone(task.CompletionData),
 821355        CompletedBy = task.CompletedBy,
 821356        CreatedAt = task.CreatedAt,
 821357        UpdatedAt = task.UpdatedAt,
 821358        AssignedAt = task.AssignedAt,
 821359        CompletedAt = task.CompletedAt,
 821360        Revision = task.Revision,
 821361        Events = [..task.Events],
 821362        Operations = [..task.Operations],
 821363        Invitations = [..task.Invitations]
 821364    };
 365
 1642366    private static JsonElement? Clone(JsonElement? value) => value is { } element ? element.Clone() : null;
 367}

Methods/Properties

.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)
Sort(System.Collections.Generic.IEnumerable`1<Elsa.UserTasks.Models.UserTask>,System.String,System.Boolean)
IsAfterCursor(Elsa.UserTasks.Models.UserTask,System.ValueTuple`3<System.String,System.String,System.String>,System.Boolean,System.String)
EncodeCursor(Elsa.UserTasks.Models.UserTask,System.String)
DecodeCursor(System.String)
NormalizeSort(System.String)
SortKind(System.String)
SortValue(Elsa.UserTasks.Models.UserTask,System.String)
EncodeSortValue(System.Object,System.String)
CompareSortValue(System.String,System.Object,System.String,System.String)
Key(System.String,System.String)
Clone(Elsa.UserTasks.Models.UserTask)
Clone(System.Nullable`1<System.Text.Json.JsonElement>)