< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopeGoogleUsersQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeGoogleUsersQueryHandler.cs
Line coverage
97%
Covered lines: 42
Uncovered lines: 1
Coverable lines: 43
Total lines: 120
Line coverage: 97.6%
Branch coverage
91%
Covered branches: 11
Total branches: 12
Branch coverage: 91.6%
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()91.66%1212100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeGoogleUsersQueryHandler.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="ListScopeGoogleUsersQuery" /> (UC-27, FR-GO-14): lists the Google Users of a
 16///     scope with pagination and optional name/email filters, excluding logically deleted records
 17///     unless explicitly requested (FR-GO-17). A missing or logically deleted scope is AF-27a
 18///     (<c>ScopeNotFound</c>); an actor who does not own the scope is AF-27b (<c>NotScopeOwner</c>).
 19///     A System Admin bypasses the ownership check.
 20/// </summary>
 21/// <remarks>
 22///     The counterpart of <see cref="GetGoogleUserByIdQueryHandler" />, and deliberately stricter: the
 23///     authorization matrix grants a Google User a read of *themselves*, never a listing, so there is
 24///     no self-read branch here. The <c>RoleRequirement</c> on the endpoint keeps every <c>User</c>
 25///     out before this runs, and this check answers for the Scope Admin the attribute cannot judge.
 26/// </remarks>
 1627public class ListScopeGoogleUsersQueryHandler(
 1628    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1629    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 1630    IScopeOwnershipChecker scopeOwnership,
 1631    IValidator<ListScopeGoogleUsersQuery> validator)
 32    : IPaginatedQueryHandlerAsync<ListScopeGoogleUsersQuery, GoogleUserOutput>
 33{
 34    public async Task<PaginatedOutput<GoogleUserOutput>> HandleAsync(ListScopeGoogleUsersQuery query, CancellationToken 
 35    {
 1636        var output = PaginatedOutput<GoogleUserOutput>.New;
 37
 38        // NFR-10: page number/size bounds and filter length, validated before any query runs.
 1639        var validation = await validator.ValidateAsync(query, cancellationToken);
 40
 1641        if (!validation.IsValid)
 42        {
 043            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 44        }
 45
 46        // AF-27a: the target scope must exist and not be logically deleted.
 1647        var scope = await scopeReader.Query()
 1648            .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted, cancellationToken);
 49
 1650        if (scope is null)
 51        {
 452            return output.WithError(GoogleUserMessages.ScopeNotFound);
 53        }
 54
 55        // AF-27b: a Scope Admin may only read a scope they own; a System Admin bypasses.
 1256        if (!await scopeOwnership.ActorMayManageScopeAsync(
 1257                query.ActingRole, query.ActingPersonId, scope.Id, cancellationToken))
 58        {
 259            return output.WithError(GoogleUserMessages.NotScopeOwner);
 60        }
 61
 1062        var googleUsers = googleUserReader.Query().Where(x => x.ScopeId == scope.Id);
 63
 64        // UC-27 step 3 (FR-GO-17).
 65        // NFR-24: a restricted identity is withheld from tenant-facing listings, whatever
 66        // IncludeDeleted says — the two states are independent. Not hidden from everyone: the
 67        // subject's own export still returns it, and a System Admin resolving the dispute can still
 68        // reach it by id. But a scope's administrator working through their user list has no
 69        // business acting on a record whose processing is suspended.
 1070        googleUsers = googleUsers.Where(x => x.ProcessingRestrictedAt == null);
 71
 1072        if (!query.IncludeDeleted)
 73        {
 874            googleUsers = googleUsers.Where(x => !x.IsDeleted);
 75        }
 76
 77        // FR-GO-14's filtering, in the vocabulary ListScopePersonsQueryHandler already uses:
 78        // case-insensitive substring matches, which translate to LOWER() … LIKE in SQL.
 1079        if (!string.IsNullOrWhiteSpace(query.Name))
 80        {
 281            var name = query.Name.ToLower();
 282            googleUsers = googleUsers.Where(x => x.Name.ToLower().Contains(name));
 83        }
 84
 1085        if (!string.IsNullOrWhiteSpace(query.Email))
 86        {
 287            var email = query.Email.ToLower();
 288            googleUsers = googleUsers.Where(x => x.Email.ToLower().Contains(email));
 89        }
 90
 1091        var projected = googleUsers.Select(x => new GoogleUserOutput
 1092        {
 1093            Id = x.PublicId,
 1094            GoogleId = x.GoogleId,
 1095            Name = x.Name,
 1096            Email = x.Email,
 1097            EmailVerified = x.EmailVerified,
 1098            ProfilePictureUrl = x.ProfilePictureUrl,
 1099            IsDeleted = x.IsDeleted,
 10100            ScopeId = x.Scope.PublicId,
 10101            CreatedAt = x.CreatedAt,
 10102            UpdatedAt = x.UpdatedAt
 10103        });
 104
 105        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 106        // ordering rather than handing PaginateAsync a sort key of its own.
 107        //
 108        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 109        // ties — each page is a separate query, free to break the tie differently. Two people called
 110        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 111        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 112        // through a list sees every row exactly once.
 10113        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 114
 10115        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null, cancellationToken: cance
 116
 117        // UC-27 step 4.
 10118        return page.WithMessage(GoogleUserMessages.GoogleUsersRetrievedSuccessfully);
 16119    }
 120}