< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Data.Persistence.ScopeOwnerInvitations
Assembly: ArturRios.Heimdall.Data
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Infrastructure/ArturRios.Heimdall.Data/Persistence/ScopeOwnerInvitations.cs
Line coverage
99%
Covered lines: 112
Uncovered lines: 1
Coverable lines: 113
Total lines: 199
Line coverage: 99.1%
Branch coverage
82%
Covered branches: 28
Total branches: 34
Branch coverage: 82.3%
Method coverage
100%
Covered methods: 10
Fully covered methods: 9
Total methods: 10
Method coverage: 100%
Full method coverage: 90%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
OpenAsync()75%4493.33%
TryEndAsync()100%44100%
TryAcceptAsync()100%22100%
<TryAcceptAsync()80%1010100%
ForgetPersonsAsync()100%22100%
<ForgetPersonsAsync()66.66%66100%
Column()100%11100%
FindOpenAsync(...)100%11100%
Record(...)83.33%66100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Infrastructure/ArturRios.Heimdall.Data/Persistence/ScopeOwnerInvitations.cs

#LineLine coverage
 1using ArturRios.Heimdall.Data.Configuration;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Domain.Enums;
 4using ArturRios.Heimdall.Domain.Persistence;
 5using Microsoft.EntityFrameworkCore;
 6using Npgsql;
 7
 8namespace ArturRios.Heimdall.Data.Persistence;
 9
 10/// <summary>
 11///     <see cref="IScopeOwnerInvitations" /> on PostgreSQL. Every end is an <c>ExecuteUpdate</c> whose
 12///     condition is "still open"; under READ COMMITTED a racing write that waits on the row re-reads it and
 13///     finds the condition false, so it writes nothing.
 14/// </summary>
 15/// <remarks>
 16///     Acceptance spans two tables, so it is a transaction, as <see cref="ApplicationCredentialSlots" /> is:
 17///     the conditional <c>UPDATE</c> first, and the <c>SCOPE_OWNER</c> insert only when it took. The insert is
 18///     targeted SQL rather than a save of the person, whose whole row must never be written back (#237).
 19/// </remarks>
 6820public sealed class ScopeOwnerInvitations(AppDbContext context) : IScopeOwnerInvitations
 21{
 22    public async Task<ScopeOwnerInvitationOpening> OpenAsync(ScopeOwnerInvitation invitation, DateTime now)
 23    {
 24        // An expired row still holds the pair's slot in the partial unique index; it ended at its expiry.
 1125        await context.ScopeOwnerInvitations
 1126            .Where(x => x.ScopeId == invitation.ScopeId && x.InviteePersonId == invitation.InviteePersonId &&
 1127                        x.EndedAt == null && x.ExpiresAt <= now)
 2228            .ExecuteUpdateAsync(set => set.SetProperty(x => x.EndedAt, x => (DateTime?)x.ExpiresAt));
 29
 1130        if (await FindOpenAsync(invitation, now) is { } open)
 31        {
 232            return new ScopeOwnerInvitationOpening(open, Created: false);
 33        }
 34
 935        context.ScopeOwnerInvitations.Add(invitation);
 36
 37        try
 38        {
 939            await context.SaveChangesAsync();
 40
 741            return new ScopeOwnerInvitationOpening(invitation, Created: true);
 42        }
 43        catch (DbUpdateException exception)
 244            when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
 45        {
 46            // A simultaneous request opened one first; the index refused this one. Answer with theirs.
 247            context.Entry(invitation).State = EntityState.Detached;
 48
 249            var winner = await FindOpenAsync(invitation, now);
 50
 251            if (winner is null)
 52            {
 053                throw;
 54            }
 55
 256            return new ScopeOwnerInvitationOpening(winner, Created: false);
 57        }
 1158    }
 59
 60    public async Task<bool> TryEndAsync(
 61        ScopeOwnerInvitation invitation, ScopeOwnerInvitationOutcomes outcome, DateTime now)
 62    {
 763        if (outcome == ScopeOwnerInvitationOutcomes.Accepted)
 64        {
 165            throw new ArgumentException("An acceptance writes an ownership too; use TryAcceptAsync.", nameof(outcome));
 66        }
 67
 668        var ended = await context.ScopeOwnerInvitations
 669            .Where(x => x.Id == invitation.Id)
 670            .Where(ScopeOwnerInvitation.OpenAt(now))
 1271            .ExecuteUpdateAsync(set => set
 1272                .SetProperty(x => x.EndedAt, now)
 1273                .SetProperty(x => x.Outcome, outcome)) == 1;
 74
 675        if (ended)
 76        {
 577            Record(invitation, now, outcome);
 78        }
 79
 680        return ended;
 681    }
 82
 83    public async Task<bool> TryAcceptAsync(ScopeOwnerInvitation invitation, DateTime now)
 84    {
 85        // The table name comes from the model, never from input, so building it into the SQL is safe; the
 86        // values stay parameters.
 2087        var ownerTable = context.Model.FindEntityType(typeof(ScopeOwner))!.GetSchemaQualifiedTableName();
 2088        var strategy = context.Database.CreateExecutionStrategy();
 89
 2090        var accepted = await strategy.ExecuteAsync(async () =>
 2091        {
 2092            await using var transaction = await context.Database.BeginTransactionAsync();
 2093
 2094            var ended = await context.ScopeOwnerInvitations
 2095                .Where(x => x.Id == invitation.Id)
 2096                .Where(ScopeOwnerInvitation.OpenAt(now))
 2097                .Where(x => context.Scopes.Any(scope => scope.Id == x.ScopeId && !scope.IsDeleted))
 2098                .Where(x => context.Persons
 2099                    .Where(ScopeOwnerInvitation.EligibleInvitee)
 20100                    .Any(person => person.Id == x.InviteePersonId))
 40101                .ExecuteUpdateAsync(set => set
 40102                    .SetProperty(x => x.EndedAt, now)
 40103                    .SetProperty(x => x.Outcome, ScopeOwnerInvitationOutcomes.Accepted)) == 1;
 20104
 20105            if (!ended)
 20106            {
 12107                await transaction.RollbackAsync();
 20108
 12109                return false;
 20110            }
 20111
 8112            var sql = $"INSERT INTO {ownerTable} (scope_id, person_id) VALUES ({{0}}, {{1}}) ON CONFLICT DO NOTHING";
 8113            await context.Database.ExecuteSqlRawAsync(sql, invitation.ScopeId, invitation.InviteePersonId);
 20114
 8115            await transaction.CommitAsync();
 20116
 8117            return true;
 20118        });
 119
 20120        if (accepted)
 121        {
 8122            Record(invitation, now, ScopeOwnerInvitationOutcomes.Accepted);
 123        }
 124
 20125        return accepted;
 40126    }
 127
 128    public async Task<IReadOnlyList<Guid>> ForgetPersonsAsync(IReadOnlyCollection<long> personIds, DateTime now)
 129    {
 15130        if (personIds.Count == 0)
 131        {
 1132            return [];
 133        }
 134
 135        // Table and column names come from the model, never from input, so building them into the SQL is
 136        // safe; the values stay parameters.
 14137        var invitationType = context.Model.FindEntityType(typeof(ScopeOwnerInvitation))!;
 14138        var table = invitationType.GetSchemaQualifiedTableName();
 14139        var endedAt = Column(nameof(ScopeOwnerInvitation.EndedAt));
 14140        var outcome = Column(nameof(ScopeOwnerInvitation.Outcome));
 14141        var invitee = Column(nameof(ScopeOwnerInvitation.InviteePersonId));
 14142        var publicId = Column(nameof(ScopeOwnerInvitation.PublicId));
 14143        var ids = personIds.ToArray();
 14144        var strategy = context.Database.CreateExecutionStrategy();
 145
 14146        return await strategy.ExecuteAsync(async () =>
 14147        {
 14148            await using var transaction = await context.Database.BeginTransactionAsync();
 14149
 14150            // RETURNING answers with exactly the rows this UPDATE changed. A row a racing answer ended first
 14151            // fails the guard, so it is neither voided nor reported: every id returned is a void that took
 14152            // effect, and the caller audits each one (G-F7).
 14153            var sql = $$"""
 14154                UPDATE {{table}} SET {{endedAt}} = {0}, {{outcome}} = {1}
 14155                WHERE {{invitee}} = ANY({2}) AND {{endedAt}} IS NULL
 14156                RETURNING {{publicId}} AS "Value"
 14157                """;
 14158            var voided = await context.Database
 14159                .SqlQueryRaw<Guid>(sql, now, (short)ScopeOwnerInvitationOutcomes.Voided, ids)
 14160                .ToListAsync();
 14161
 14162            await context.ScopeOwnerInvitations
 14163                .Where(x => x.InvitedByPersonId != null && ids.Contains(x.InvitedByPersonId.Value))
 28164                .ExecuteUpdateAsync(set => set.SetProperty(x => x.InvitedByPersonId, (long?)null));
 14165
 14166            await transaction.CommitAsync();
 14167
 14168            return (IReadOnlyList<Guid>)voided;
 14169        });
 170
 56171        string Column(string property) => invitationType.FindProperty(property)!.GetColumnName();
 29172    }
 173
 174    private Task<ScopeOwnerInvitation?> FindOpenAsync(ScopeOwnerInvitation invitation, DateTime now) =>
 13175        context.ScopeOwnerInvitations.AsNoTracking()
 13176            .Where(x => x.ScopeId == invitation.ScopeId && x.InviteePersonId == invitation.InviteePersonId)
 13177            .Where(ScopeOwnerInvitation.OpenAt(now))
 13178            .FirstOrDefaultAsync();
 179
 180    /// <summary>
 181    ///     Brings the caller's instance in line with a write the change tracker did not see, as
 182    ///     <c>AtomicWrites</c> does, so a later save does not write the old values back.
 183    /// </summary>
 184    private void Record(ScopeOwnerInvitation invitation, DateTime endedAt, ScopeOwnerInvitationOutcomes outcome)
 185    {
 13186        invitation.EndedAt = endedAt;
 13187        invitation.Outcome = outcome;
 188
 13189        var entry = context.Entry(invitation);
 190
 13191        if (entry.State is EntityState.Unchanged or EntityState.Modified)
 192        {
 8193            entry.Property(x => x.EndedAt).OriginalValue = endedAt;
 8194            entry.Property(x => x.EndedAt).IsModified = false;
 8195            entry.Property(x => x.Outcome).OriginalValue = outcome;
 8196            entry.Property(x => x.Outcome).IsModified = false;
 197        }
 13198    }
 199}