< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Query.Handlers.ListErasureRequestsQueryHandler
Assembly: ArturRios.Heimdall.Query
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListErasureRequestsQueryHandler.cs
Line coverage
97%
Covered lines: 43
Uncovered lines: 1
Coverable lines: 44
Total lines: 106
Line coverage: 97.7%
Branch coverage
83%
Covered branches: 5
Total branches: 6
Branch coverage: 83.3%
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()83.33%66100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Query/Handlers/ListErasureRequestsQueryHandler.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.Mediator.Query.Interfaces;
 7using ArturRios.Output;
 8using FluentValidation;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace ArturRios.Heimdall.Query.Handlers;
 12
 13/// <summary>
 14///     Handles <see cref="ListErasureRequestsQuery" /> (UC-43): lists the erasure requests that have
 15///     been made and not yet carried out, across both identity tables, soonest deadline first.
 16/// </summary>
 17/// <remarks>
 18///     <para>
 19///         Authorization is entirely the endpoint's: UC-43's only actor is the System Admin, and the
 20///         <c>RoleRequirement</c> settles it, so there is no data-dependent rule left here. That is
 21///         deliberate rather than an omission — a Scope Admin seeing the erasure requests of the
 22///         users in their scope would learn which of them had asked to leave, which is not something
 23///         the request itself entitles them to know.
 24///     </para>
 25///     <para>
 26///         The two tables are read separately and combined in memory rather than joined. They have
 27///         no relationship — the whole design keeps them apart (TH-21) — and the result set is
 28///         bounded by what is outstanding at one moment, which is a queue of things needing human
 29///         attention rather than a table scan.
 30///     </para>
 31/// </remarks>
 932public class ListErasureRequestsQueryHandler(
 933    IAsyncReadOnlyRepository<Person, long> personReader,
 934    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 935    IValidator<ListErasureRequestsQuery> validator)
 36    : IPaginatedQueryHandlerAsync<ListErasureRequestsQuery, ErasureRequestOutput>
 37{
 38    public async Task<PaginatedOutput<ErasureRequestOutput>> HandleAsync(ListErasureRequestsQuery query, CancellationTok
 39    {
 940        var output = PaginatedOutput<ErasureRequestOutput>.New;
 41
 42        // NFR-10: page number and size bounds, validated before any query runs.
 943        var validation = await validator.ValidateAsync(query);
 44
 945        if (!validation.IsValid)
 46        {
 047            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 48        }
 49
 950        var now = DateTime.UtcNow;
 51
 52        // Outstanding means asked for and not yet carried out. A record already anonymised is done,
 53        // whatever else is true of it.
 954        var persons = await personReader.Query()
 955            .Where(person => person.ErasureRequestedAt != null && person.AnonymisedAt == null)
 956            .Select(person => new ErasureRequestOutput
 957            {
 958                SubjectId = person.PublicId,
 959                IsGoogleUser = false,
 960                RequestedAt = person.ErasureRequestedAt!.Value,
 961                DueAt = person.ErasureDueAt!.Value,
 962                BlockedReason = person.ErasureBlockedReason
 963            })
 964            .ToListAsync();
 65
 966        var googleUsers = await googleUserReader.Query()
 967            .Where(googleUser => googleUser.ErasureRequestedAt != null && googleUser.AnonymisedAt == null)
 968            .Select(googleUser => new ErasureRequestOutput
 969            {
 970                SubjectId = googleUser.PublicId,
 971                IsGoogleUser = true,
 972                RequestedAt = googleUser.ErasureRequestedAt!.Value,
 973                DueAt = googleUser.ErasureDueAt!.Value,
 974                BlockedReason = googleUser.ErasureBlockedReason
 975            })
 976            .ToListAsync();
 77
 978        var requests = persons.Concat(googleUsers).ToList();
 79
 80        // Computed here rather than in the projection: "overdue" is a comparison against the moment
 81        // the queue is read, not a stored fact, and computing it in SQL would have each of the two
 82        // queries take its own reading of the clock.
 5083        foreach (var request in requests)
 84        {
 1685            request.Overdue = request.DueAt <= now;
 86        }
 87
 988        if (query.OverdueOnly)
 89        {
 390            requests = requests.Where(request => request.Overdue).ToList();
 91        }
 92
 93        // Soonest deadline first, with the subject's identifier as a tiebreaker so two requests
 94        // sharing a deadline cannot straddle a page boundary — the same reasoning the person
 95        // listings document.
 996        var ordered = requests
 1597            .OrderBy(request => request.DueAt)
 1598            .ThenBy(request => request.SubjectId)
 999            .AsQueryable();
 100
 9101        var page = await Task.FromResult(
 9102            ordered.Paginate(query.PageNumber, query.PageSize, orderBy: null));
 103
 9104        return page.WithMessage(ErasureMessages.ErasureRequestsRetrieved);
 9105    }
 106}