< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopeAdminsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeAdminsQueryHandler.cs
Line coverage
100%
Covered lines: 44
Uncovered lines: 0
Coverable lines: 44
Total lines: 129
Line coverage: 100%
Branch coverage
100%
Covered branches: 16
Total branches: 16
Branch coverage: 100%
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

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

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Domain.Enums;
 4using ArturRios.Heimdall.Query.Input;
 5using ArturRios.Heimdall.Query.Output;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Heimdall.Shared.Services;
 8using ArturRios.Mediator.Query.Interfaces;
 9using ArturRios.Output;
 10using FluentValidation;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Query.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="ListScopeAdminsQuery" /> (UC-07 read d, FR-PE-12): lists every live
 17///     administrator who may own a scope — every <c>ScopeAdmin</c>, and for a System Admin caller
 18///     every <c>SystemAdmin</c> too — with pagination and optional name/email filters, projected to
 19///     identifier, name, and email only. Backs UI-11's owner selector and UI-14's "add an existing
 20///     Scope Admin".
 21/// </summary>
 22/// <remarks>
 23///     <para>
 24///         Both administrator roles may call it, which is wider than UC-07's per-person visibility
 25///         rule allows for a Scope Admin. That widening is deliberate and is what makes UI-14 step 3
 26///         possible at all — a co-owner being added does not yet own the scope, so the existing rule
 27///         could never surface them. The three-field projection is what keeps it safe; see
 28///         <see cref="PersonSummaryOutput" />.
 29///     </para>
 30///     <para>
 31///         <c>ExcludeOwnersOfScopeId</c> is gated on scope ownership even though the projection is
 32///         minimal, because the parameter is not a projection question: calling the endpoint twice,
 33///         once with it and once without, and diffing the results enumerates the owners of whatever
 34///         scope was named. The gate makes that possible only for a scope the caller already manages.
 35///     </para>
 36/// </remarks>
 2237public class ListScopeAdminsQueryHandler(
 2238    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2239    IAsyncReadOnlyRepository<Person, long> personReader,
 2240    IScopeOwnershipChecker scopeOwnership,
 2241    IValidator<ListScopeAdminsQuery> validator)
 42    : IPaginatedQueryHandlerAsync<ListScopeAdminsQuery, PersonSummaryOutput>
 43{
 44    public async Task<PaginatedOutput<PersonSummaryOutput>> HandleAsync(ListScopeAdminsQuery query, CancellationToken ca
 45    {
 2246        var output = PaginatedOutput<PersonSummaryOutput>.New;
 47
 48        // NFR-10: page number/size bounds and filter length, validated before any query runs.
 2249        var validation = await validator.ValidateAsync(query);
 50
 2251        if (!validation.IsValid)
 52        {
 253            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 54        }
 55
 2156        long? excludedScopeId = null;
 57
 2158        if (query.ExcludeOwnersOfScopeId is not null)
 59        {
 60            // AF-07a: the named scope must exist and not be logically deleted.
 861            var scope = await scopeReader.Query()
 862                .FirstOrDefaultAsync(x => x.PublicId == query.ExcludeOwnersOfScopeId.Value && !x.IsDeleted);
 63
 864            if (scope is null)
 65            {
 366                return output.WithError(PersonMessages.ScopeNotFound);
 67            }
 68
 69            // AF-07b: only an owner of the named scope (or a System Admin) may subtract its owners,
 70            // since a with/without diff would otherwise reveal them.
 571            if (!await scopeOwnership.ActorMayManageScopeAsync(query.ActingRole, query.ActingPersonId, scope.Id))
 72            {
 273                return output.WithError(PersonMessages.NotScopeOwner);
 74            }
 75
 376            excludedScopeId = scope.Id;
 377        }
 78
 79        // Who may own a scope (FR-SC-08): a ScopeAdmin, or a SystemAdmin. System Admins are offered
 80        // only to a System Admin — their addresses are not tenant-facing, and a Scope Admin choosing
 81        // co-owners for their scope has no need of them.
 1682        var ownerRoles = query.ActingRole == (int)Roles.SystemAdmin
 1683            ? new[] { (long)Roles.ScopeAdmin, (long)Roles.SystemAdmin }
 1684            : [(long)Roles.ScopeAdmin];
 85
 86        // A logically deleted administrator is never a valid owner, so this listing has no
 87        // include-deleted mode at all — see ListScopeAdminsQuery.
 1688        var admins = personReader.Query()
 1689            .Where(x => ownerRoles.Contains(x.RoleId) && !x.IsDeleted
 1690                        // NFR-24: withheld from tenant-facing listings, as in ListScopePersons.
 1691                        && x.ProcessingRestrictedAt == null);
 92
 1693        if (excludedScopeId is not null)
 94        {
 95            // Before pagination, so a page of the requested size comes back full (UI-14 AF-14c).
 396            admins = admins.Where(x => x.ScopeOwnerships.All(ownership => ownership.ScopeId != excludedScopeId.Value));
 97        }
 98
 1699        if (!string.IsNullOrWhiteSpace(query.Name))
 100        {
 6101            var name = query.Name.ToLower();
 6102            admins = admins.Where(x => x.Name.ToLower().Contains(name));
 103        }
 104
 16105        if (!string.IsNullOrWhiteSpace(query.Email))
 106        {
 2107            var email = query.Email.ToLower();
 2108            admins = admins.Where(x => x.Email.ToLower().Contains(email));
 109        }
 110
 16111        var projected = admins.Select(x => new PersonSummaryOutput
 16112        {
 16113            Id = x.PublicId,
 16114            Name = x.Name,
 16115            Email = x.Email
 16116        });
 117
 118        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 119        // ordering — the same reasoning ListScopePersonsQueryHandler documents: names are not
 120        // unique, PostgreSQL gives no ordering guarantee between tied sort keys, and each page is a
 121        // separate query, so without the tiebreaker two administrators sharing a name could straddle
 122        // a page boundary and appear on both pages while a third appeared on neither.
 16123        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 124
 16125        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 126
 16127        return page.WithMessage(PersonMessages.PersonsRetrievedSuccessfully);
 22128    }
 129}