< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.DeleteScopeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/DeleteScopeCommandHandler.cs
Line coverage
97%
Covered lines: 39
Uncovered lines: 1
Coverable lines: 40
Total lines: 129
Line coverage: 97.5%
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%6696.77%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Domain.Entities;
 5using ArturRios.Heimdall.Domain.Persistence;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Mediator.Command.Interfaces;
 8using ArturRios.Output;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace ArturRios.Heimdall.Command.Handlers;
 12
 13/// <summary>
 14///     Handles <see cref="DeleteScopeCommand" /> (UC-04): locates the scope (AF-04a), then logically
 15///     deletes it and cascades <c>IsDeleted = true</c> to its Users (via <c>SCOPE_USER</c>), Google
 16///     Users, and applications. Owners (<c>SCOPE_OWNER</c>) are never modified. An already-deleted
 17///     scope is answered as deleted (AF-04b), its cascade re-run for any member still live. The
 18///     response reports the totals of the scope's members, counted regardless of their individual
 19///     deletion state. All failures are returned as errors on the <see cref="DataOutput{T}" /> or
 20///     thrown, and none of them keeps a partial cascade.
 21/// </summary>
 22/// <remarks>
 23///     <para>
 24///         <b>All or nothing</b> (#204). The scope's flag and the cascade are written in one
 25///         <see cref="IWriteTransactions" /> transaction, so a failure part-way leaves the scope and every
 26///         member as they were, and the next attempt starts from the beginning. They used to be separate
 27///         saves: a failure after the flag left a deleted scope with live members, and the retry, finding
 28///         the scope deleted, skipped the cascade and answered <c>200</c>.
 29///     </para>
 30///     <para>
 31///         <b>A retry finishes the cascade.</b> AF-04b leaves the scope as it is but runs the cascade
 32///         again, which touches only members not yet deleted, so a scope left half-cascaded — by a
 33///         version before the transaction — is repaired by deleting it again, and an intact one costs
 34///         three statements that change nothing.
 35///     </para>
 36///     <para>
 37///         <b>Set-based</b> (<see cref="IScopeCascades" />). The members are never loaded: the cascade is
 38///         one conditional <c>UPDATE</c> per kind of member, and the totals are counts. A tenant of any
 39///         size costs the request the same memory.
 40///     </para>
 41///     <para>
 42///         <b>Under the scope's lock</b> (#202). The transaction first locks the scope's row
 43///         (<see cref="IInvariantLocks" />), which every writer that can take an owner out of the scope
 44///         also holds, and reads the scope again under it. So the deletion and an owner change of the same
 45///         scope run one after the other: an owner removal that follows finds the scope gone (AF-22a),
 46///         and the deletion never acts on a scope read before another request deleted it.
 47///     </para>
 48///     <para>
 49///         <b>The flag alone</b> (#205). The scope is written by a targeted write of <c>IsDeleted</c> and
 50///         <c>UpdatedAt</c>, not a save of the copy read before the lock, which would write the scope's
 51///         name, description and settings back as they were read, over a change made in between.
 52///     </para>
 53/// </remarks>
 1654public class DeleteScopeCommandHandler(
 1655    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1656    IAtomicWrites atomicWrites,
 1657    IAsyncReadOnlyRepository<Person, long> personReader,
 1658    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 1659    IAsyncReadOnlyRepository<Application, long> applicationReader,
 1660    IScopeCascades scopeCascades,
 1661    IWriteTransactions transactions,
 1662    IInvariantLocks locks)
 63    : ICommandHandlerAsync<DeleteScopeCommand, DeleteScopeCommandOutput>
 64{
 65    public async Task<DataOutput<DeleteScopeCommandOutput?>> HandleAsync(DeleteScopeCommand command, CancellationToken c
 66    {
 1667        var output = DataOutput<DeleteScopeCommandOutput?>.New;
 68
 69        // Step 2 (AF-04a): locate the scope in ANY deletion state, so an already-deleted scope is
 70        // handled idempotently (AF-04b) rather than reported as not found.
 1671        var scope = await scopeReader.Query().FirstOrDefaultAsync(x => x.PublicId == command.Id);
 72
 1673        if (scope is null)
 74        {
 275            return output.WithError(ScopeMessages.ScopeNotFound);
 76        }
 77
 1478        var now = DateTime.UtcNow;
 79
 80        // Steps 3-4 land together or not at all (#204): leaving this block by any return or exception
 81        // before the commit rolls both back.
 1482        await using var transaction = await transactions.BeginAsync();
 1483        await locks.LockAsync([scope.Id]);
 84
 85        // The scope's deletion state as it stands under the lock, not as read above: the tracked copy
 86        // keeps the values it was read with. Gone altogether is AF-04a's answer.
 1487        var isDeleted = await scopeReader.Query()
 1488            .Where(x => x.Id == scope.Id)
 1489            .Select(x => (bool?)x.IsDeleted)
 1490            .FirstOrDefaultAsync();
 91
 1492        if (isDeleted is null)
 93        {
 094            return output.WithError(ScopeMessages.ScopeNotFound);
 95        }
 96
 97        // Step 3: flip the scope itself, and nothing else of it. AF-04b: an already-deleted scope is left
 98        // as it is. Under the lock nothing else writes the row, so the write cannot find it deleted.
 1499        if (isDeleted == false)
 100        {
 10101            await atomicWrites.TryMarkScopeDeletedAsync(scope, now);
 102        }
 103
 104        // Step 4, for a new deletion and for AF-04b alike: every member not yet deleted. It is an
 105        // administrative deletion like any other (NFR-20): nobody asked to be erased, so the identities
 106        // it sweeps up get the reversal window rather than the statutory deadline. Members already
 107        // deleted keep the time and kind of their own deletion (#237).
 14108        await scopeCascades.MarkMembersDeletedAsync(scope.Id, now);
 109
 110        // Step 5's totals, counted regardless of individual deletion state.
 13111        var userCount = await personReader.Query()
 13112            .CountAsync(p => p.ScopeMembership != null && p.ScopeMembership.ScopeId == scope.Id);
 13113        var googleUserCount = await googleUserReader.Query().CountAsync(g => g.ScopeId == scope.Id);
 13114        var applicationCount = await applicationReader.Query().CountAsync(a => a.ScopeId == scope.Id);
 115
 13116        await transaction.CommitAsync();
 117
 118        // Step 5: return the scope id and the member totals.
 13119        return output
 13120            .WithData(new DeleteScopeCommandOutput
 13121            {
 13122                Id = scope.PublicId,
 13123                UserCount = userCount,
 13124                GoogleUserCount = googleUserCount,
 13125                ApplicationCount = applicationCount
 13126            })
 13127            .WithMessage(ScopeMessages.ScopeDeletedSuccessfully);
 15128    }
 129}