< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopePersonsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopePersonsQueryHandler.cs
Line coverage
98%
Covered lines: 50
Uncovered lines: 1
Coverable lines: 51
Total lines: 122
Line coverage: 98%
Branch coverage
92%
Covered branches: 13
Total branches: 14
Branch coverage: 92.8%
Method coverage
100%
Covered methods: 2
Fully covered methods: 1
Total methods: 2
Method coverage: 100%
Full method coverage: 50%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()92.85%1414100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopePersonsQueryHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Query.Input;
 4using ArturRios.Heimdall.Query.Output;
 5using ArturRios.Heimdall.Shared.Messages;
 6using ArturRios.Heimdall.Shared.Services;
 7using ArturRios.Mediator.Query.Interfaces;
 8using ArturRios.Output;
 9using FluentValidation;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Query.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="ListScopePersonsQuery" /> (UC-07, FR-PE-04): lists the <c>User</c> persons
 16///     of a scope with pagination and optional name/email filters, excluding logically deleted
 17///     persons unless explicitly requested (FR-PE-08). A missing or logically deleted scope is AF-07a
 18///     (<c>ScopeNotFound</c>); an actor who does not own the scope is AF-07b (<c>NotScopeOwner</c>).
 19///     A System Admin bypasses the ownership check.
 20/// </summary>
 3721public class ListScopePersonsQueryHandler(
 3722    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 3723    IAsyncReadOnlyRepository<Person, long> personReader,
 3724    IScopeOwnershipChecker scopeOwnership,
 3725    IValidator<ListScopePersonsQuery> validator)
 26    : IPaginatedQueryHandlerAsync<ListScopePersonsQuery, PersonOutput>
 27{
 28    public async Task<PaginatedOutput<PersonOutput>> HandleAsync(ListScopePersonsQuery query, CancellationToken cancella
 29    {
 3730        var output = PaginatedOutput<PersonOutput>.New;
 31
 32        // NFR-10: page number/size bounds and filter length, validated before any query runs.
 3733        var validation = await validator.ValidateAsync(query);
 34
 3735        if (!validation.IsValid)
 36        {
 037            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 38        }
 39
 40        // AF-07a: the target scope must exist and not be logically deleted.
 3741        var scope = await scopeReader.Query()
 3742            .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted);
 43
 3744        if (scope is null)
 45        {
 346            return output.WithError(PersonMessages.ScopeNotFound);
 47        }
 48
 49        // AF-07b: a Scope Admin may only read a scope they own; a System Admin bypasses.
 3450        if (!await scopeOwnership.ActorMayManageScopeAsync(query.ActingRole, query.ActingPersonId, scope.Id))
 51        {
 352            return output.WithError(PersonMessages.NotScopeOwner);
 53        }
 54
 3155        var persons = personReader.Query()
 3156            .Where(x => x.ScopeMembership != null && x.ScopeMembership.ScopeId == scope.Id);
 57
 58        // NFR-24: a restricted identity is withheld from tenant-facing listings, whatever
 59        // IncludeDeleted says — the two states are independent. Not hidden from everyone: the
 60        // subject's own export still returns it, and a System Admin resolving the dispute can still
 61        // reach it by id. But a scope's administrator working through their user list has no
 62        // business acting on a record whose processing is suspended.
 3163        persons = persons.Where(x => x.ProcessingRestrictedAt == null);
 64
 3165        if (!query.IncludeDeleted)
 66        {
 3067            persons = persons.Where(x => !x.IsDeleted);
 68        }
 69
 3170        if (!string.IsNullOrWhiteSpace(query.Name))
 71        {
 272            var name = query.Name.ToLower();
 273            persons = persons.Where(x => x.Name.ToLower().Contains(name));
 74        }
 75
 3176        if (!string.IsNullOrWhiteSpace(query.Email))
 77        {
 178            var email = query.Email.ToLower();
 179            persons = persons.Where(x => x.Email.ToLower().Contains(email));
 80        }
 81
 82        // FR-PE-19, as in ListScopeOwners: a Scope Admin sees only the ownerships of scopes they own
 83        // too, a System Admin all of them. A User owns no scope (FR-SC-08), so this normally trims
 84        // nothing; it is applied so the projection cannot say more than the rule allows.
 3185        var visibleScopeIds = await OwnedScopeVisibility.ScopesVisibleToAsync(
 3186            personReader, query.ActingRole, query.ActingPersonId);
 3187        var seesEveryOwnership = visibleScopeIds is null;
 3188        var visible = visibleScopeIds ?? [];
 89
 3190        var projected = persons.Select(x => new PersonOutput
 3191        {
 3192            Id = x.PublicId,
 3193            Name = x.Name,
 3194            Email = x.Email,
 3195            Role = (int)x.RoleId,
 3196            EmailVerified = x.EmailVerified,
 3197            TwoFactorEnabled = x.TwoFactorAuth != null && x.TwoFactorAuth.IsActive,
 3198            IsDeleted = x.IsDeleted,
 3199            ScopeId = x.ScopeMembership != null ? x.ScopeMembership.Scope.PublicId : null,
 31100            OwnedScopeIds = x.ScopeOwnerships
 31101                .Where(ownership => seesEveryOwnership || visible.Contains(ownership.ScopeId))
 31102                .Select(ownership => ownership.Scope.PublicId)
 31103                .ToList(),
 31104            CreatedAt = x.CreatedAt,
 31105            UpdatedAt = x.UpdatedAt
 31106        });
 107
 108        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 109        // ordering rather than handing PaginateAsync a sort key of its own.
 110        //
 111        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 112        // ties — each page is a separate query, free to break the tie differently. Two people called
 113        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 114        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 115        // through a list sees every row exactly once.
 31116        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 117
 31118        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 119
 31120        return page.WithMessage(PersonMessages.PersonsRetrievedSuccessfully);
 37121    }
 122}