< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.AcceptScopeOwnerInvitationCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/AcceptScopeOwnerInvitationCommandHandler.cs
Line coverage
98%
Covered lines: 52
Uncovered lines: 1
Coverable lines: 53
Total lines: 155
Line coverage: 98.1%
Branch coverage
95%
Covered branches: 19
Total branches: 20
Branch coverage: 95%
Method coverage
100%
Covered methods: 5
Fully covered methods: 4
Total methods: 5
Method coverage: 100%
Full method coverage: 80%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
HandleAsync()100%1414100%
NoLongerHonourableAsync()100%44100%
VoidAsync()50%2287.5%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/AcceptScopeOwnerInvitationCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Auditing;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Command.Services;
 6using ArturRios.Heimdall.Domain.Entities;
 7using ArturRios.Heimdall.Domain.Enums;
 8using ArturRios.Heimdall.Domain.Persistence;
 9using ArturRios.Heimdall.Shared.Messages;
 10using ArturRios.Mediator.Command.Interfaces;
 11using ArturRios.Output;
 12using Microsoft.EntityFrameworkCore;
 13using Microsoft.Extensions.Logging;
 14
 15namespace ArturRios.Heimdall.Command.Handlers;
 16
 17/// <summary>
 18///     Handles <see cref="AcceptScopeOwnerInvitationCommand" /> (UC-60, FR-SC-17): the invitee, signed in as
 19///     themselves, accepts an open invitation, which ends it as accepted and makes them an owner of the scope
 20///     in one transaction; the inviter is then told. All failures are returned as errors on the
 21///     <see cref="DataOutput{T}" /> rather than thrown.
 22/// </summary>
 23/// <remarks>
 24///     <para>
 25///         <b>One answer for every refusal (AF-60a).</b> An unknown id, somebody else's invitation, an ended or
 26///         expired one, and one that a withdrawal or another answer ended between the read and the write all
 27///         answer "The invitation was not found or is no longer open." The inviter accepting their own
 28///         invitation is told the same as an outsider.
 29///     </para>
 30///     <para>
 31///         <b>An invitation that can no longer be honoured is voided (AF-60b).</b> The scope must still be live
 32///         and the invitee <see cref="ScopeOwnerInvitation.EligibleInvitee" />. If not, the invitation ends as
 33///         voided, the void is audited with the invitation as target, and the answer is AF-60a's. The
 34///         acceptance's own write re-checks both conditions, so a change landing after this check cannot slip
 35///         through; when that write is refused, the invitation is read again and, if it is still open but no
 36///         longer honourable, voided the same way.
 37///     </para>
 38///     <para>
 39///         The acceptance (<see cref="IScopeOwnerInvitations.TryAcceptAsync" />) opens its own transaction, so
 40///         this handler opens none around it.
 41///     </para>
 42/// </remarks>
 2543public class AcceptScopeOwnerInvitationCommandHandler(
 2544    IAsyncReadOnlyRepository<Person, long> personReader,
 2545    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2546    IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> invitationReader,
 2547    IScopeOwnerInvitations invitations,
 2548    IScopeInvitationNoticeSender notices,
 2549    IAuditLogWriter auditLog,
 2550    ILogger<AcceptScopeOwnerInvitationCommandHandler> logger)
 51    : ICommandHandlerAsync<AcceptScopeOwnerInvitationCommand, AcceptScopeOwnerInvitationCommandOutput>
 52{
 253    private static readonly Func<Person, bool> EligibleInvitee = ScopeOwnerInvitation.EligibleInvitee.Compile();
 54
 55    public async Task<DataOutput<AcceptScopeOwnerInvitationCommandOutput?>> HandleAsync(
 56        AcceptScopeOwnerInvitationCommand command, CancellationToken cancellationToken = default)
 57    {
 2558        var output = DataOutput<AcceptScopeOwnerInvitationCommandOutput?>.New;
 2559        var now = DateTime.UtcNow;
 60
 61        // AF-60a.
 2562        var found = await InviteeInvitationLookup.FindAsync(
 2563            personReader, invitationReader, command.ActingPersonId, command.InvitationId, now, cancellationToken);
 64
 2565        if (found is null)
 66        {
 567            return output.WithError(ScopeInvitationMessages.InvitationNotOpen);
 68        }
 69
 2070        var (invitee, invitation) = found.Value;
 71
 2072        var scope = await scopeReader.Query()
 2073            .FirstOrDefaultAsync(x => x.Id == invitation.ScopeId, cancellationToken);
 74
 75        // AF-60b. EligibleInvitee covers the person only; the scope's liveness is checked beside it.
 2076        if (scope is null || scope.IsDeleted || !EligibleInvitee(invitee))
 77        {
 478            await VoidAsync(invitation, now);
 79
 480            return output.WithError(ScopeInvitationMessages.InvitationNotOpen);
 81        }
 82
 83        // UC-60 step 3: ends the invitation and inserts the ownership together, or neither (AF-60a).
 1684        if (!await invitations.TryAcceptAsync(invitation, now))
 85        {
 86            // AF-60b landing between the check above and the write: void it if it is still open.
 687            if (await NoLongerHonourableAsync(invitation, now, cancellationToken))
 88            {
 389                await VoidAsync(invitation, now);
 90            }
 91
 692            return output.WithError(ScopeInvitationMessages.InvitationNotOpen);
 93        }
 94
 1095        if (await InviteeInvitationLookup.InviterToTellAsync(personReader, invitation, cancellationToken) is { } inviter
 96        {
 397            await notices.SendAnswerAsync(inviter.Email, invitee.Name, scope.Name, accepted: true);
 98        }
 99
 10100        return output
 10101            .WithData(new AcceptScopeOwnerInvitationCommandOutput { Id = invitation.PublicId, ScopeId = scope.PublicId }
 10102            .WithMessage(ScopeInvitationMessages.InvitationAccepted);
 25103    }
 104
 105    /// <summary>
 106    ///     After a refused acceptance, reads the invitation again: <c>true</c> when it is still open and its scope
 107    ///     is no longer live or its invitee no longer <see cref="ScopeOwnerInvitation.EligibleInvitee" />;
 108    ///     <c>false</c> when something else ended it, a withdrawal or another answer, which stands. The checks
 109    ///     run in the query, so they see the stored rows rather than the instances read earlier.
 110    /// </summary>
 111    private async Task<bool> NoLongerHonourableAsync(
 112        ScopeOwnerInvitation invitation, DateTime now, CancellationToken cancellationToken)
 113    {
 6114        var stillOpen = await invitationReader.Query()
 6115            .Where(x => x.Id == invitation.Id)
 6116            .Where(ScopeOwnerInvitation.OpenAt(now))
 6117            .AnyAsync(cancellationToken);
 118
 6119        if (!stillOpen)
 120        {
 3121            return false;
 122        }
 123
 3124        var scopeLive = await scopeReader.Query()
 3125            .AnyAsync(x => x.Id == invitation.ScopeId && !x.IsDeleted, cancellationToken);
 3126        var inviteeEligible = await personReader.Query()
 3127            .Where(x => x.Id == invitation.InviteePersonId)
 3128            .Where(ScopeOwnerInvitation.EligibleInvitee)
 3129            .AnyAsync(cancellationToken);
 130
 3131        return !scopeLive || !inviteeEligible;
 6132    }
 133
 134    /// <summary>
 135    ///     Ends the invitation as voided and, only if that write took effect, records the void (NFR-09). The
 136    ///     decorator records this request's own refusal; the void is a second event, with the invitation as
 137    ///     target. Like the decorator, a failed audit write is logged and the answer stands.
 138    /// </summary>
 139    private async Task VoidAsync(ScopeOwnerInvitation invitation, DateTime now)
 140    {
 7141        if (!await invitations.TryEndAsync(invitation, ScopeOwnerInvitationOutcomes.Voided, now))
 142        {
 0143            return;
 144        }
 145
 146        try
 147        {
 7148            await auditLog.WriteAsync(ScopeOwnerInvitationAuditActions.Voided, invitation.PublicId, true, null);
 6149        }
 1150        catch (Exception exception)
 151        {
 1152            logger.LogWarning(exception, "Failed to audit the void of invitation {InvitationId}", invitation.PublicId);
 1153        }
 7154    }
 155}