< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.DeleteScopeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/DeleteScopeCommandHandler.cs
Line coverage
96%
Covered lines: 57
Uncovered lines: 2
Coverable lines: 59
Total lines: 134
Line coverage: 96.6%
Branch coverage
78%
Covered branches: 11
Total branches: 14
Branch coverage: 78.5%
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.44%
CascadeAsync()83.33%66100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/DeleteScopeCommandHandler.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.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="DeleteScopeCommand" /> (UC-04): locates the scope (AF-04a), then logically
 16///     deletes it and cascades <c>IsDeleted = true</c> to its Users (via <c>SCOPE_USER</c>), Google
 17///     Users, and applications. Owners (<c>SCOPE_OWNER</c>) are never modified. An already-deleted
 18///     scope is an idempotent no-op (AF-04b). The response reports the totals of the scope's members,
 19///     counted regardless of their individual deletion state. All failures are returned as errors on
 20///     the <see cref="DataOutput{T}" /> rather than thrown.
 21/// </summary>
 922public class DeleteScopeCommandHandler(
 923    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 924    IAsyncRepository<Scope, long> scopeWriter,
 925    IAsyncReadOnlyRepository<Person, long> personReader,
 926    IAsyncRepository<Person, long> personWriter,
 927    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 928    IAsyncRepository<GoogleUser, long> googleUserWriter,
 929    IAsyncReadOnlyRepository<Application, long> applicationReader,
 930    IAsyncRepository<Application, long> applicationWriter)
 31    : ICommandHandlerAsync<DeleteScopeCommand, DeleteScopeCommandOutput>
 32{
 33    public async Task<DataOutput<DeleteScopeCommandOutput?>> HandleAsync(DeleteScopeCommand command, CancellationToken c
 34    {
 935        var output = DataOutput<DeleteScopeCommandOutput?>.New;
 36
 37        // Step 2 (AF-04a): locate the scope in ANY deletion state, so an already-deleted scope is
 38        // handled idempotently (AF-04b) rather than reported as not found.
 939        var scope = await scopeReader.Query().FirstOrDefaultAsync(x => x.PublicId == command.Id);
 40
 941        if (scope is null)
 42        {
 243            return output.WithError(ScopeMessages.ScopeNotFound);
 44        }
 45
 46        // Step 3: the scope's members, counted regardless of individual deletion state (both flows).
 747        var users = await personReader.Query()
 748            .Where(p => p.ScopeMembership != null && p.ScopeMembership.ScopeId == scope.Id)
 749            .ToListAsync();
 750        var googleUsers = await googleUserReader.Query()
 751            .Where(g => g.ScopeId == scope.Id)
 752            .ToListAsync();
 753        var applications = await applicationReader.Query()
 754            .Where(a => a.ScopeId == scope.Id)
 755            .ToListAsync();
 56
 57        // AF-04b: an already-deleted scope is left untouched; the totals are still reported below.
 758        if (!scope.IsDeleted)
 59        {
 560            var now = DateTime.UtcNow;
 61
 62            // Step 4: flip the scope itself.
 63            // The cascade is an administrative deletion like any other (NFR-20): nobody asked to be
 64            // erased, so the identities it sweeps up get the reversal window rather than the
 65            // statutory deadline.
 66            const int administrative = (int)DeletionKinds.Administrative;
 67
 568            scope.IsDeleted = true;
 569            scope.UpdatedAt = now;
 70
 571            var scopeUpdate = await scopeWriter.UpdateAsync(scope);
 72
 573            if (!scopeUpdate.Success)
 74            {
 075                return output.WithErrors(scopeUpdate.Errors);
 76            }
 77
 78            // Step 5: cascade to the members that are not already deleted.
 1179            var cascadeErrors = (await CascadeAsync(users, p => p.IsDeleted,
 2580                    p => { p.IsDeleted = true; p.DeletedAt = now; p.DeletionKind = administrative; p.UpdatedAt = now; },
 581                    personWriter))
 382                .Concat(await CascadeAsync(googleUsers, g => g.IsDeleted,
 1583                    g => { g.IsDeleted = true; g.DeletedAt = now; g.DeletionKind = administrative; g.UpdatedAt = now; },
 584                    googleUserWriter))
 385                .Concat(await CascadeAsync(applications, a => a.IsDeleted,
 986                    a => { a.IsDeleted = true; a.UpdatedAt = now; }, applicationWriter))
 587                .ToList();
 88
 589            if (cascadeErrors.Count > 0)
 90            {
 091                return output.WithErrors(cascadeErrors);
 92            }
 593        }
 94
 95        // Step 6: return the scope id and the member totals.
 796        return output
 797            .WithData(new DeleteScopeCommandOutput
 798            {
 799                Id = scope.PublicId,
 7100                UserCount = users.Count,
 7101                GoogleUserCount = googleUsers.Count,
 7102                ApplicationCount = applications.Count
 7103            })
 7104            .WithMessage(ScopeMessages.ScopeDeletedSuccessfully);
 9105    }
 106
 107    /// <summary>
 108    ///     Flips <c>IsDeleted</c> (and <c>UpdatedAt</c>, via <paramref name="markDeleted" />) on the
 109    ///     members that are not already deleted, then persists them. Returns any persistence errors,
 110    ///     or an empty sequence when there is nothing to update.
 111    /// </summary>
 112    private static async Task<IEnumerable<string>> CascadeAsync<T>(
 113        IEnumerable<T> members,
 114        Func<T, bool> isDeleted,
 115        Action<T> markDeleted,
 116        IAsyncRepository<T, long> writer) where T : Entity<long>
 117    {
 27118        var pending = members.Where(member => !isDeleted(member)).ToList();
 119
 15120        if (pending.Count == 0)
 121        {
 6122            return [];
 123        }
 124
 40125        foreach (var member in pending)
 126        {
 11127            markDeleted(member);
 128        }
 129
 9130        var result = await writer.UpdateRangeAsync(pending);
 131
 9132        return result.Success ? [] : result.Errors;
 15133    }
 134}