< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.UpdateScopeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/UpdateScopeCommandHandler.cs
Line coverage
97%
Covered lines: 43
Uncovered lines: 1
Coverable lines: 44
Total lines: 97
Line coverage: 97.7%
Branch coverage
87%
Covered branches: 7
Total branches: 8
Branch coverage: 87.5%
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()87.5%8897.43%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/UpdateScopeCommandHandler.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.Shared.Messages;
 6using ArturRios.Mediator.Command.Interfaces;
 7using ArturRios.Output;
 8using FluentValidation;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace ArturRios.Heimdall.Command.Handlers;
 12
 13/// <summary>
 14///     Handles <see cref="UpdateScopeCommand" /> (UC-03): validates the request, verifies the scope
 15///     exists and is not logically deleted (AF-03a), verifies the new name does not collide with
 16///     another scope (AF-03b), then applies the changes and stamps <c>UpdatedAt</c>. All failures are
 17///     returned as errors on the <see cref="DataOutput{T}" /> rather than thrown, using the canonical
 18///     <see cref="ScopeMessages" /> so the response resolver can pick the matching status code.
 19/// </summary>
 1420public class UpdateScopeCommandHandler(
 1421    IValidator<UpdateScopeCommand> validator,
 1422    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1423    IAsyncRepository<Scope, long> scopeWriter)
 24    : ICommandHandlerAsync<UpdateScopeCommand, UpdateScopeCommandOutput>
 25{
 26    public async Task<DataOutput<UpdateScopeCommandOutput?>> HandleAsync(UpdateScopeCommand command, CancellationToken c
 27    {
 1428        var output = DataOutput<UpdateScopeCommandOutput?>.New;
 29
 30        // Step 2: validate input fields.
 1431        var validation = await validator.ValidateAsync(command);
 32
 1433        if (!validation.IsValid)
 34        {
 435            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 36        }
 37
 38        // Step 3 (AF-03a): the scope must exist and not be logically deleted.
 1239        var scope = await scopeReader.Query()
 1240            .FirstOrDefaultAsync(x => x.PublicId == command.Id && !x.IsDeleted);
 41
 1242        if (scope is null)
 43        {
 444            return output.WithError(ScopeMessages.ScopeNotFound);
 45        }
 46
 47        // Step 4 (AF-03b): the new name must not collide with another scope, compared
 48        // case-insensitively (LOWER() in SQL) so the app layer authoritatively rejects case variants
 49        // that the case-sensitive DB unique index on Name would not catch. Checked against all scopes
 50        // (deleted included), excluding this scope so an unchanged name is not a false conflict.
 851        var nameTaken = await scopeReader.Query()
 852            .AnyAsync(x => x.Name.ToLower() == command.Name.ToLower() && x.PublicId != command.Id);
 53
 854        if (nameTaken)
 55        {
 356            return output.WithError(ScopeMessages.NameAlreadyExists);
 57        }
 58
 59        // Owner PublicIds for the response. A projection over the navigation (no Include needed),
 60        // which EF translates to a join and the in-memory fake evaluates directly.
 561        var ownerIds = await scopeReader.Query()
 562            .Where(x => x.PublicId == command.Id)
 563            .SelectMany(x => x.Owners.Select(owner => owner.Person.PublicId))
 564            .ToListAsync();
 65
 66        // Step 4 (main flow): apply the updates and stamp UpdatedAt (no DB trigger maintains it).
 567        scope.Name = command.Name;
 568        scope.Description = command.Description;
 569        scope.DefaultLegalBasis = command.DefaultLegalBasis;
 570        scope.PrivacyNoticeUri = command.PrivacyNoticeUri;
 571        scope.UpdatedAt = DateTime.UtcNow;
 72
 573        var update = await scopeWriter.UpdateAsync(scope);
 74
 575        if (!update.Success)
 76        {
 077            return output.WithErrors(update.Errors);
 78        }
 79
 80        // Step 5: return the updated scope.
 581        return output
 582            .WithData(new UpdateScopeCommandOutput
 583            {
 584                Id = scope.PublicId,
 585                Name = scope.Name,
 586                Description = scope.Description,
 587                GoogleSignInEnabled = scope.GoogleSignInEnabled,
 588                SelfRegistrationEnabled = scope.SelfRegistrationEnabled,
 589                EmailVerificationUrl = scope.EmailVerificationUrl,
 590                PasswordResetUrl = scope.PasswordResetUrl,
 591                OwnerIds = ownerIds,
 592                CreatedAt = scope.CreatedAt,
 593                UpdatedAt = scope.UpdatedAt
 594            })
 595            .WithMessage(ScopeMessages.ScopeUpdatedSuccessfully);
 1496    }
 97}