< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopePersonsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopePersonsQueryHandler.cs
Line coverage
98%
Covered lines: 51
Uncovered lines: 1
Coverable lines: 52
Total lines: 123
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/ci/agent/workspace/heimdall-api-docs_main/app/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, cancellationToken);
 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, cancellationToken);
 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(
 3451                query.ActingRole, query.ActingPersonId, scope.Id, cancellationToken))
 52        {
 353            return output.WithError(PersonMessages.NotScopeOwner);
 54        }
 55
 3156        var persons = personReader.Query()
 3157            .Where(x => x.ScopeMembership != null && x.ScopeMembership.ScopeId == scope.Id);
 58
 59        // NFR-24: a restricted identity is withheld from tenant-facing listings, whatever
 60        // IncludeDeleted says — the two states are independent. Not hidden from everyone: the
 61        // subject's own export still returns it, and a System Admin resolving the dispute can still
 62        // reach it by id. But a scope's administrator working through their user list has no
 63        // business acting on a record whose processing is suspended.
 3164        persons = persons.Where(x => x.ProcessingRestrictedAt == null);
 65
 3166        if (!query.IncludeDeleted)
 67        {
 3068            persons = persons.Where(x => !x.IsDeleted);
 69        }
 70
 3171        if (!string.IsNullOrWhiteSpace(query.Name))
 72        {
 273            var name = query.Name.ToLower();
 274            persons = persons.Where(x => x.Name.ToLower().Contains(name));
 75        }
 76
 3177        if (!string.IsNullOrWhiteSpace(query.Email))
 78        {
 179            var email = query.Email.ToLower();
 180            persons = persons.Where(x => x.Email.ToLower().Contains(email));
 81        }
 82
 83        // FR-PE-19, as in ListScopeOwners: a Scope Admin sees only the ownerships of scopes they own
 84        // too, a System Admin all of them. A User owns no scope (FR-SC-08), so this normally trims
 85        // nothing; it is applied so the projection cannot say more than the rule allows.
 3186        var visibleScopeIds = await OwnedScopeVisibility.ScopesVisibleToAsync(
 3187            personReader, query.ActingRole, query.ActingPersonId, cancellationToken);
 3188        var seesEveryOwnership = visibleScopeIds is null;
 3189        var visible = visibleScopeIds ?? [];
 90
 3191        var projected = persons.Select(x => new PersonOutput
 3192        {
 3193            Id = x.PublicId,
 3194            Name = x.Name,
 3195            Email = x.Email,
 3196            Role = (int)x.RoleId,
 3197            EmailVerified = x.EmailVerified,
 3198            TwoFactorEnabled = x.TwoFactorAuth != null && x.TwoFactorAuth.IsActive,
 3199            IsDeleted = x.IsDeleted,
 31100            ScopeId = x.ScopeMembership != null ? x.ScopeMembership.Scope.PublicId : null,
 31101            OwnedScopeIds = x.ScopeOwnerships
 31102                .Where(ownership => seesEveryOwnership || visible.Contains(ownership.ScopeId))
 31103                .Select(ownership => ownership.Scope.PublicId)
 31104                .ToList(),
 31105            CreatedAt = x.CreatedAt,
 31106            UpdatedAt = x.UpdatedAt
 31107        });
 108
 109        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 110        // ordering rather than handing PaginateAsync a sort key of its own.
 111        //
 112        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 113        // ties — each page is a separate query, free to break the tie differently. Two people called
 114        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 115        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 116        // through a list sees every row exactly once.
 31117        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 118
 31119        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null, cancellationToken: cance
 120
 31121        return page.WithMessage(PersonMessages.PersonsRetrievedSuccessfully);
 37122    }
 123}