< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopeApplicationsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeApplicationsQueryHandler.cs
Line coverage
97%
Covered lines: 39
Uncovered lines: 1
Coverable lines: 40
Total lines: 113
Line coverage: 97.5%
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/ListScopeApplicationsQueryHandler.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;
 12using Application = ArturRios.Heimdall.Domain.Entities.Application;
 13
 14namespace ArturRios.Heimdall.Query.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="ListScopeApplicationsQuery" /> (UC-17, FR-AP-05): lists a scope's
 18///     applications with pagination and optional name/owner filters, excluding logically deleted
 19///     applications unless explicitly requested (FR-AP-09). A System Admin sees every application in
 20///     the scope; any other actor must own the scope and sees only the applications they own. A
 21///     missing or logically deleted scope is AF-17a (<c>ScopeNotFound</c>); an actor who does not own
 22///     the scope is AF-17b (<c>NotScopeOwner</c>).
 23/// </summary>
 2224public class ListScopeApplicationsQueryHandler(
 2225    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2226    IAsyncReadOnlyRepository<Application, long> applicationReader,
 2227    IScopeOwnershipChecker scopeOwnership,
 2228    IValidator<ListScopeApplicationsQuery> validator)
 29    : IPaginatedQueryHandlerAsync<ListScopeApplicationsQuery, ApplicationOutput>
 30{
 31    public async Task<PaginatedOutput<ApplicationOutput>> HandleAsync(ListScopeApplicationsQuery query, CancellationToke
 32    {
 2233        var output = PaginatedOutput<ApplicationOutput>.New;
 34
 35        // NFR-10: page number/size bounds and filter length, validated before any query runs.
 2236        var validation = await validator.ValidateAsync(query, cancellationToken);
 37
 2238        if (!validation.IsValid)
 39        {
 040            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 41        }
 42
 43        // AF-17a: the target scope must exist and not be logically deleted.
 2244        var scope = await scopeReader.Query()
 2245            .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted, cancellationToken);
 46
 2247        if (scope is null)
 48        {
 449            return output.WithError(ApplicationMessages.ScopeNotFound);
 50        }
 51
 52        // AF-17b: a Scope Admin may only list a scope they own; a System Admin bypasses. The gate is
 53        // not redundant with the owner narrowing below: without it, an actor probing an unrelated
 54        // scope would get an empty 200, indistinguishable from a scope that is genuinely empty.
 1855        if (!await scopeOwnership.ActorMayManageScopeAsync(
 1856                query.ActingRole, query.ActingPersonId, scope.Id, cancellationToken))
 57        {
 258            return output.WithError(ApplicationMessages.NotScopeOwner);
 59        }
 60
 1661        var applications = applicationReader.Query().Where(x => x.ScopeId == scope.Id);
 62
 63        // UC-17 step 2: a System Admin sees every application in the scope; a Scope Admin sees only
 64        // the ones they own, even among co-owners of the same scope.
 1665        if (query.ActingRole != (int)Roles.SystemAdmin)
 66        {
 367            applications = applications.Where(x => x.Owner.PublicId == query.ActingPersonId);
 68        }
 69
 70        // FR-AP-09: logically deleted applications are excluded unless explicitly requested.
 1671        if (!query.IncludeDeleted)
 72        {
 1473            applications = applications.Where(x => !x.IsDeleted);
 74        }
 75
 76        // Compared case-insensitively (LOWER() in SQL), as every name filter in this codebase is.
 1677        if (!string.IsNullOrWhiteSpace(query.Name))
 78        {
 279            var name = query.Name.ToLower();
 280            applications = applications.Where(x => x.Name.ToLower().Contains(name));
 81        }
 82
 1683        if (query.OwnerId.HasValue)
 84        {
 285            applications = applications.Where(x => x.Owner.PublicId == query.OwnerId.Value);
 86        }
 87
 1688        var projected = applications.Select(x => new ApplicationOutput
 1689        {
 1690            Id = x.PublicId,
 1691            Name = x.Name,
 1692            ScopeId = x.Scope.PublicId,
 1693            OwnerId = x.Owner.PublicId,
 1694            IsDeleted = x.IsDeleted,
 1695            CreatedAt = x.CreatedAt,
 1696            UpdatedAt = x.UpdatedAt
 1697        });
 98
 99        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 100        // ordering rather than handing PaginateAsync a sort key of its own.
 101        //
 102        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 103        // ties — each page is a separate query, free to break the tie differently. Two people called
 104        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 105        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 106        // through a list sees every row exactly once.
 16107        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 108
 16109        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null, cancellationToken: cance
 110
 16111        return page.WithMessage(ApplicationMessages.ApplicationsRetrievedSuccessfully);
 22112    }
 113}