< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListScopeApplicationsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListScopeApplicationsQueryHandler.cs
Line coverage
97%
Covered lines: 38
Uncovered lines: 1
Coverable lines: 39
Total lines: 112
Line coverage: 97.4%
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/runner/work/heimdall-api/heimdall-api/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);
 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);
 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(query.ActingRole, query.ActingPersonId, scope.Id))
 56        {
 257            return output.WithError(ApplicationMessages.NotScopeOwner);
 58        }
 59
 1660        var applications = applicationReader.Query().Where(x => x.ScopeId == scope.Id);
 61
 62        // UC-17 step 2: a System Admin sees every application in the scope; a Scope Admin sees only
 63        // the ones they own, even among co-owners of the same scope.
 1664        if (query.ActingRole != (int)Roles.SystemAdmin)
 65        {
 366            applications = applications.Where(x => x.Owner.PublicId == query.ActingPersonId);
 67        }
 68
 69        // FR-AP-09: logically deleted applications are excluded unless explicitly requested.
 1670        if (!query.IncludeDeleted)
 71        {
 1472            applications = applications.Where(x => !x.IsDeleted);
 73        }
 74
 75        // Compared case-insensitively (LOWER() in SQL), as every name filter in this codebase is.
 1676        if (!string.IsNullOrWhiteSpace(query.Name))
 77        {
 278            var name = query.Name.ToLower();
 279            applications = applications.Where(x => x.Name.ToLower().Contains(name));
 80        }
 81
 1682        if (query.OwnerId.HasValue)
 83        {
 284            applications = applications.Where(x => x.Owner.PublicId == query.OwnerId.Value);
 85        }
 86
 1687        var projected = applications.Select(x => new ApplicationOutput
 1688        {
 1689            Id = x.PublicId,
 1690            Name = x.Name,
 1691            ScopeId = x.Scope.PublicId,
 1692            OwnerId = x.Owner.PublicId,
 1693            IsDeleted = x.IsDeleted,
 1694            CreatedAt = x.CreatedAt,
 1695            UpdatedAt = x.UpdatedAt
 1696        });
 97
 98        // Ordered by name with the public identifier as a tiebreaker, then paginated over that
 99        // ordering rather than handing PaginateAsync a sort key of its own.
 100        //
 101        // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key
 102        // ties — each page is a separate query, free to break the tie differently. Two people called
 103        // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody
 104        // else appeared on neither. The tiebreaker makes the total order deterministic, so paging
 105        // through a list sees every row exactly once.
 16106        var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id);
 107
 16108        var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null);
 109
 16110        return page.WithMessage(ApplicationMessages.ApplicationsRetrievedSuccessfully);
 22111    }
 112}