< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopeOwnerInvitationsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeOwnerInvitationsQueryHandler.cs
Line coverage
100%
Covered lines: 37
Uncovered lines: 0
Coverable lines: 37
Total lines: 87
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
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%66100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeOwnerInvitationsQueryHandler.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="ListScopeOwnerInvitationsQuery" /> (UC-58, FR-SC-15): a scope's open owner
 16///     invitations, newest first, for a System Admin or an owner of the scope.
 17/// </summary>
 18/// <remarks>
 19///     A missing or logically deleted scope is AF-58a; a caller who may not manage it is AF-58b. Only open
 20///     invitations are listed — not ended, not expired — and not those of a logically deleted invitee, who
 21///     could never accept, nor of a processing-restricted one (NFR-24). The inviter's name is shown only for
 22///     a live, unrestricted inviter. The listing answers 200 with no message.
 23/// </remarks>
 1324public class ListScopeOwnerInvitationsQueryHandler(
 1325    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1326    IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> invitationReader,
 1327    IScopeOwnershipChecker scopeOwnership,
 1328    IValidator<ListScopeOwnerInvitationsQuery> validator)
 29    : IPaginatedQueryHandlerAsync<ListScopeOwnerInvitationsQuery, ScopeOwnerInvitationOutput>
 30{
 31    public async Task<PaginatedOutput<ScopeOwnerInvitationOutput>> HandleAsync(
 32        ListScopeOwnerInvitationsQuery query, CancellationToken cancellationToken = default)
 33    {
 1334        var output = PaginatedOutput<ScopeOwnerInvitationOutput>.New;
 35
 36        // NFR-10: page bounds, before any query runs.
 1337        var validation = await validator.ValidateAsync(query, cancellationToken);
 38
 1339        if (!validation.IsValid)
 40        {
 241            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 42        }
 43
 44        // AF-58a.
 1245        var scope = await scopeReader.Query()
 1246            .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted, cancellationToken);
 47
 1248        if (scope is null)
 49        {
 150            return output.WithError(ScopeMessages.ScopeNotFound);
 51        }
 52
 53        // AF-58b.
 1154        if (!await scopeOwnership.ActorMayManageScopeAsync(
 1155                query.ActingRole, query.ActingPersonId, scope.Id, cancellationToken))
 56        {
 257            return output.WithError(ScopeMessages.NotScopeOwner);
 58        }
 59
 960        var now = DateTime.UtcNow;
 61
 62        // Newest first with the internal id as tiebreaker, ordered on the entity because the id never
 63        // reaches the output, then projected and paginated over that ordering.
 64        // NFR-24: a restricted identity is withheld from tenant-facing listings — the invitee's row and the
 65        // inviter's name alike, as ListScopeOwners does.
 966        var projected = invitationReader.Query()
 967            .Where(x => x.ScopeId == scope.Id && !x.Invitee.IsDeleted && x.Invitee.ProcessingRestrictedAt == null)
 968            .Where(ScopeOwnerInvitation.OpenAt(now))
 969            .OrderByDescending(x => x.CreatedAt)
 970            .ThenByDescending(x => x.Id)
 971            .Select(x => new ScopeOwnerInvitationOutput
 972            {
 973                Id = x.PublicId,
 974                InviteeId = x.Invitee.PublicId,
 975                InviteeName = x.Invitee.Name,
 976                InviteeEmail = x.Invitee.Email,
 977                InvitedByName =
 978                    x.InvitedBy != null && !x.InvitedBy.IsDeleted && x.InvitedBy.ProcessingRestrictedAt == null
 979                        ? x.InvitedBy.Name
 980                        : null,
 981                CreatedAt = x.CreatedAt,
 982                ExpiresAt = x.ExpiresAt
 983            });
 84
 985        return await projected.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null, cancellationToken: cancell
 1386    }
 87}