< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopeGoogleUsersQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeGoogleUsersQueryHandler.cs
Line coverage
97%
Covered lines: 41
Uncovered lines: 1
Coverable lines: 42
Total lines: 119
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/runner/work/heimdall-api/heimdall-api/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);
 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);
 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(query.ActingRole, query.ActingPersonId, scope.Id))
 57        {
 258            return output.WithError(GoogleUserMessages.NotScopeOwner);
 59        }
 60
 1061        var googleUsers = googleUserReader.Query().Where(x => x.ScopeId == scope.Id);
 62
 63        // UC-27 step 3 (FR-GO-17).
 64        // NFR-24: a restricted identity is withheld from tenant-facing listings, whatever
 65        // IncludeDeleted says — the two states are independent. Not hidden from everyone: the
 66        // subject's own export still returns it, and a System Admin resolving the dispute can still
 67        // reach it by id. But a scope's administrator working through their user list has no
 68        // business acting on a record whose processing is suspended.
 1069        googleUsers = googleUsers.Where(x => x.ProcessingRestrictedAt == null);
 70
 1071        if (!query.IncludeDeleted)
 72        {
 873            googleUsers = googleUsers.Where(x => !x.IsDeleted);
 74        }
 75
 76        // FR-GO-14's filtering, in the vocabulary ListScopePersonsQueryHandler already uses:
 77        // case-insensitive substring matches, which translate to LOWER() … LIKE in SQL.
 1078        if (!string.IsNullOrWhiteSpace(query.Name))
 79        {
 280            var name = query.Name.ToLower();
 281            googleUsers = googleUsers.Where(x => x.Name.ToLower().Contains(name));
 82        }
 83
 1084        if (!string.IsNullOrWhiteSpace(query.Email))
 85        {
 286            var email = query.Email.ToLower();
 287            googleUsers = googleUsers.Where(x => x.Email.ToLower().Contains(email));
 88        }
 89
 1090        var projected = googleUsers.Select(x => new GoogleUserOutput
 1091        {
 1092            Id = x.PublicId,
 1093            GoogleId = x.GoogleId,
 1094            Name = x.Name,
 1095            Email = x.Email,
 1096            EmailVerified = x.EmailVerified,
 1097            ProfilePictureUrl = x.ProfilePictureUrl,
 1098            IsDeleted = x.IsDeleted,
 1099            ScopeId = x.Scope.PublicId,
 10100            CreatedAt = x.CreatedAt,
 10101            UpdatedAt = x.UpdatedAt
 10102        });
 103
 104        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 105        // ordering rather than handing PaginateAsync a sort key of its own.
 106        //
 107        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 108        // ties — each page is a separate query, free to break the tie differently. Two people called
 109        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 110        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 111        // through a list sees every row exactly once.
 10112        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 113
 10114        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 115
 116        // UC-27 step 4.
 10117        return page.WithMessage(GoogleUserMessages.GoogleUsersRetrievedSuccessfully);
 16118    }
 119}