< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.HardDeleteScopeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/HardDeleteScopeCommandHandler.cs
Line coverage
96%
Covered lines: 61
Uncovered lines: 2
Coverable lines: 63
Total lines: 145
Line coverage: 96.8%
Branch coverage
75%
Covered branches: 9
Total branches: 12
Branch coverage: 75%
Method coverage
100%
Covered methods: 3
Fully covered methods: 2
Total methods: 3
Method coverage: 100%
Full method coverage: 66.6%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()75%8894.87%
DeleteAllAsync()75%44100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/HardDeleteScopeCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Entities;
 2using ArturRios.Data.Relational.Core.Interfaces;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="HardDeleteScopeCommand" /> (UC-05): locates the scope (AF-05a), then
 17///     permanently deletes its Users (via <c>SCOPE_USER</c>), Google Users, and applications, and
 18///     finally the scope itself — whose <c>ON DELETE CASCADE</c> foreign keys remove the
 19///     <c>SCOPE_OWNER</c>/<c>SCOPE_USER</c> join rows and any remaining scope permissions. Owner
 20///     person records (<c>ScopeAdmin</c>s) are never removed. The response reports the totals of
 21///     the scope's members and permissions, counted regardless of their individual deletion state.
 22///     All failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 23/// </summary>
 24/// <remarks>
 25///     Before anything is removed, the <c>PublicId</c> of every member person and Google User about
 26///     to go is tombstoned (<see cref="ErasedSubject" />, NFR-21, #195), one per row, in one write.
 27///     Audit entries name their target by that bare id, so without the tombstone nothing would say,
 28///     once the rows are gone, that those entries name people whose targets must be cleared. The
 29///     scope's own id is not tombstoned: a scope is not a person, and its entries stay as recorded.
 30///     Written first, a failure later leaves tombstones for live rows, which nothing acts on; and a
 31///     retry writes them again without error.
 32/// </remarks>
 1133public class HardDeleteScopeCommandHandler(
 1134    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1135    IAsyncRepository<Scope, long> scopeWriter,
 1136    IAsyncReadOnlyRepository<Person, long> personReader,
 1137    IAsyncRepository<Person, long> personWriter,
 1138    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 1139    IAsyncRepository<GoogleUser, long> googleUserWriter,
 1140    IAsyncReadOnlyRepository<Application, long> applicationReader,
 1141    IAsyncRepository<Application, long> applicationWriter,
 1142    IAsyncReadOnlyRepository<ScopePermission, long> scopePermissionReader,
 1143    IErasedSubjects erasedSubjects)
 44    : ICommandHandlerAsync<HardDeleteScopeCommand, HardDeleteScopeCommandOutput>
 45{
 46    public async Task<DataOutput<HardDeleteScopeCommandOutput?>> HandleAsync(HardDeleteScopeCommand command, Cancellatio
 47    {
 1148        var output = DataOutput<HardDeleteScopeCommandOutput?>.New;
 49
 50        // Step 2 (AF-05a): locate the scope in ANY deletion state — an already logically-deleted
 51        // scope can still be hard-deleted.
 1152        var scope = await scopeReader.Query().FirstOrDefaultAsync(x => x.PublicId == command.Id);
 53
 1154        if (scope is null)
 55        {
 256            return output.WithError(ScopeMessages.ScopeNotFound);
 57        }
 58
 59        // The scope's members, counted regardless of individual deletion state: the rows steps 3
 60        // and 4 act on, and the totals step 7 reports.
 961        var users = await personReader.Query()
 962            .Where(p => p.ScopeMembership != null && p.ScopeMembership.ScopeId == scope.Id)
 963            .ToListAsync();
 964        var googleUsers = await googleUserReader.Query()
 965            .Where(g => g.ScopeId == scope.Id)
 966            .ToListAsync();
 967        var applications = await applicationReader.Query()
 968            .Where(a => a.ScopeId == scope.Id)
 969            .ToListAsync();
 970        var scopePermissionCount = await scopePermissionReader.Query()
 971            .CountAsync(p => p.ScopeId == scope.Id);
 72
 73        // Step 3 (NFR-21, #195): tombstone exactly the persons and Google Users step 4 removes,
 74        // before any of it happens. Owners are not members and are not removed, so they are not
 75        // tombstoned.
 976        var erased = users
 877            .Select(user => new ErasedSubject
 878            {
 879                Id = user.PublicId, Kind = (int)ErasedSubjectKinds.Person, CreatedAt = DateTime.UtcNow
 880            })
 481            .Concat(googleUsers.Select(googleUser => new ErasedSubject
 482            {
 483                Id = googleUser.PublicId, Kind = (int)ErasedSubjectKinds.GoogleUser, CreatedAt = DateTime.UtcNow
 484            }))
 985            .ToList();
 86
 987        if (erased.Count > 0)
 88        {
 589            await erasedSubjects.RecordAsync(erased);
 90        }
 91
 92        // Step 4: delete the members explicitly, in an order that never violates a foreign key
 93        // (applications reference their owning person, so they go first).
 994        var deleteErrors = (await DeleteAllAsync(applications, applicationWriter))
 995            .Concat(await DeleteAllAsync(googleUsers, googleUserWriter))
 996            .Concat(await DeleteAllAsync(users, personWriter))
 997            .ToList();
 98
 999        if (deleteErrors.Count > 0)
 100        {
 0101            return output.WithErrors(deleteErrors);
 102        }
 103
 104        // Steps 5-6: delete the scope; its ON DELETE CASCADE foreign keys clear the SCOPE_OWNER and any
 105        // remaining SCOPE_USER join rows, as well as any remaining scope permissions. Owner person
 106        // records are untouched.
 9107        var scopeDelete = await scopeWriter.DeleteAsync(scope);
 108
 9109        if (!scopeDelete.Success)
 110        {
 0111            return output.WithErrors(scopeDelete.Errors);
 112        }
 113
 114        // Step 7: return the scope id and the member/permission totals.
 9115        return output
 9116            .WithData(new HardDeleteScopeCommandOutput
 9117            {
 9118                Id = scope.PublicId,
 9119                UserCount = users.Count,
 9120                GoogleUserCount = googleUsers.Count,
 9121                ApplicationCount = applications.Count,
 9122                ScopePermissionCount = scopePermissionCount
 9123            })
 9124            .WithMessage(ScopeMessages.ScopeHardDeletedSuccessfully);
 11125    }
 126
 127    /// <summary>
 128    ///     Permanently removes every entity in <paramref name="members" /> by internal Id, or does
 129    ///     nothing when the set is empty. Returns any persistence errors, or an empty sequence on
 130    ///     success / no-op.
 131    /// </summary>
 132    private static async Task<IEnumerable<string>> DeleteAllAsync<T>(
 133        IReadOnlyCollection<T> members,
 134        IAsyncRepository<T, long> writer) where T : Entity<long>
 135    {
 27136        if (members.Count == 0)
 137        {
 12138            return [];
 139        }
 140
 33141        var result = await writer.DeleteRangeAsync(members.Select(member => member.Id));
 142
 15143        return result.Success ? [] : result.Errors;
 27144    }
 145}