< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.UpdateApplicationCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/UpdateApplicationCommandHandler.cs
Line coverage
98%
Covered lines: 49
Uncovered lines: 1
Coverable lines: 50
Total lines: 127
Line coverage: 98%
Branch coverage
93%
Covered branches: 15
Total branches: 16
Branch coverage: 93.7%
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()93.75%161697.67%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/UpdateApplicationCommandHandler.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.Enums;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Mediator.Command.Interfaces;
 8using ArturRios.Output;
 9using FluentValidation;
 10using Microsoft.EntityFrameworkCore;
 11using Application = ArturRios.Heimdall.Domain.Entities.Application;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="UpdateApplicationCommand" /> (UC-18, FR-AP-06): validates input, loads the
 17///     application inside the addressed scope (AF-18a), enforces the acting role's rule — a System
 18///     Admin may update any application, anyone else only the ones they own (AF-18c) — verifies a new
 19///     owner satisfies FR-AP-03 when the owner actually changes (AF-18b), then applies the changes and
 20///     stamps <c>UpdatedAt</c>. All failures are returned as errors on the output rather than thrown.
 21/// </summary>
 3122public class UpdateApplicationCommandHandler(
 3123    IValidator<UpdateApplicationCommand> validator,
 3124    IAsyncReadOnlyRepository<Application, long> applicationReader,
 3125    IAsyncReadOnlyRepository<Person, long> personReader,
 3126    IAsyncRepository<Application, long> applicationWriter)
 27    : ICommandHandlerAsync<UpdateApplicationCommand, UpdateApplicationCommandOutput>
 28{
 29    public async Task<DataOutput<UpdateApplicationCommandOutput?>> HandleAsync(UpdateApplicationCommand command, Cancell
 30    {
 3131        var output = DataOutput<UpdateApplicationCommandOutput?>.New;
 32
 33        // UC-18 step 2: validate input shape. UC-18 defines no alternative flow for this, so the
 34        // validator's UC-16 messages carry their existing 400.
 3135        var validation = await validator.ValidateAsync(command);
 36
 3137        if (!validation.IsValid)
 38        {
 439            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 40        }
 41
 42        // AF-18a: the application must exist inside the addressed scope and not be logically deleted.
 43        // The route's scopeId qualifies the lookup, as UC-17's by-id read does: an application that
 44        // lives in another scope is not the resource this path addresses. Checked before
 45        // authorization so AF-18a and AF-18c both stay observable.
 2946        var application = await applicationReader.Query()
 2947            .Include(x => x.Scope)
 2948            .Include(x => x.Owner)
 2949            .ThenInclude(owner => owner.ScopeOwnerships)
 2950            .FirstOrDefaultAsync(x =>
 2951                x.PublicId == command.Id && x.Scope.PublicId == command.ScopeId && !x.IsDeleted);
 52
 2953        if (application is null)
 54        {
 755            return output.WithError(ApplicationMessages.ApplicationNotFound);
 56        }
 57
 2258        var currentOwnerId = application.Owner.PublicId;
 59
 60        // AF-18c (UC-18 step 3): a System Admin may update any application; anyone else must own it.
 61        // Owning the *scope* is not by itself grounds to modify another owner's application, so the
 62        // rule compares the owner rather than consulting IScopeOwnershipChecker — the same call
 63        // UC-17 makes for reads.
 64        //
 65        // An owner who no longer owns the application's scope (UC-22 removed them) is not a valid
 66        // owner under FR-AP-03, and is refused as UC-17 refuses them.
 2267        if (command.ActingRole != (int)Roles.SystemAdmin &&
 2268            (currentOwnerId != command.ActingPersonId ||
 2669             application.Owner.ScopeOwnerships.All(ownership => ownership.ScopeId != application.ScopeId)))
 70        {
 471            return output.WithError(ApplicationMessages.NotAuthorizedToUpdateApplication);
 72        }
 73
 1874        var newOwnerId = currentOwnerId;
 75
 76        // AF-18b (UC-18 step 4): only *a change of* owner is verified. Verifying unconditionally
 77        // would refuse an ordinary rename whenever the existing owner had since been logically
 78        // deleted or lost their SCOPE_OWNER row — a refusal UC-18 does not define, and one that would
 79        // leave such an application uneditable.
 1880        if (command.OwnerId != currentOwnerId)
 81        {
 82            // FR-AP-03: the new owner must be an existing, non-logically-deleted administrator — a
 83            // ScopeAdmin or a SystemAdmin — who owns the application's scope. Same shape as UC-16's
 84            // owner check, against the scope the application already belongs to (FR-AP-02 fixes
 85            // that at creation time).
 1186            var newOwner = await personReader.Query().FirstOrDefaultAsync(person =>
 1187                person.PublicId == command.OwnerId && !person.IsDeleted &&
 1188                (person.RoleId == (long)Roles.ScopeAdmin || person.RoleId == (long)Roles.SystemAdmin) &&
 1189                person.ScopeOwnerships.Any(ownership => ownership.ScopeId == application.ScopeId));
 90
 1191            if (newOwner is null)
 92            {
 893                return output.WithError(ApplicationMessages.OwnerNotValidForScope);
 94            }
 95
 96            // Both the foreign key and the navigation are set, so the tracked entity carries one
 97            // consistent answer whichever of the two the change tracker reads.
 398            application.OwnerId = newOwner.Id;
 399            application.Owner = newOwner;
 3100            newOwnerId = newOwner.PublicId;
 101        }
 102
 103        // UC-18 step 5: apply the updates and stamp UpdatedAt (no DB trigger maintains it).
 10104        application.Name = command.Name;
 10105        application.UpdatedAt = DateTime.UtcNow;
 106
 10107        var update = await applicationWriter.UpdateAsync(application);
 108
 10109        if (!update.Success)
 110        {
 0111            return output.WithErrors(update.Errors);
 112        }
 113
 114        // UC-18 step 6: return the updated application.
 10115        return output
 10116            .WithData(new UpdateApplicationCommandOutput
 10117            {
 10118                Id = application.PublicId,
 10119                Name = application.Name,
 10120                ScopeId = application.Scope.PublicId,
 10121                OwnerId = newOwnerId,
 10122                CreatedAt = application.CreatedAt,
 10123                UpdatedAt = application.UpdatedAt
 10124            })
 10125            .WithMessage(ApplicationMessages.ApplicationUpdatedSuccessfully);
 31126    }
 127}