< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.UpdateScopeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/UpdateScopeCommandHandler.cs
Line coverage
100%
Covered lines: 40
Uncovered lines: 0
Coverable lines: 40
Total lines: 107
Line coverage: 100%
Branch coverage
100%
Covered branches: 10
Total branches: 10
Branch coverage: 100%
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%1010100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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.Domain.Persistence;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Mediator.Command.Interfaces;
 8using ArturRios.Output;
 9using FluentValidation;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="UpdateScopeCommand" /> (UC-03): validates the request, verifies the scope
 16///     exists and is not logically deleted (AF-03a), verifies the new name does not collide with
 17///     another scope (AF-03b), then applies the changes and stamps <c>UpdatedAt</c>. All failures are
 18///     returned as errors on the <see cref="DataOutput{T}" /> rather than thrown, using the canonical
 19///     <see cref="ScopeMessages" /> so the response resolver can pick the matching status code.
 20/// </summary>
 21/// <remarks>
 22///     The changes are one targeted write of the columns this request carries (#205). Google sign-in,
 23///     self-registration and the email links are left as they stand: they have endpoints of their own
 24///     (UC-24, FR-SR-02, the email-links one), and this request does not carry them, so it never
 25///     changes them.
 26/// </remarks>
 1827public class UpdateScopeCommandHandler(
 1828    IValidator<UpdateScopeCommand> validator,
 1829    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1830    IAtomicWrites atomicWrites)
 31    : ICommandHandlerAsync<UpdateScopeCommand, UpdateScopeCommandOutput>
 32{
 33    public async Task<DataOutput<UpdateScopeCommandOutput?>> HandleAsync(UpdateScopeCommand command, CancellationToken c
 34    {
 1835        var output = DataOutput<UpdateScopeCommandOutput?>.New;
 36
 37        // Step 2: validate input fields.
 1838        var validation = await validator.ValidateAsync(command);
 39
 1840        if (!validation.IsValid)
 41        {
 442            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 43        }
 44
 45        // Step 3 (AF-03a): the scope must exist and not be logically deleted.
 1646        var scope = await scopeReader.Query()
 1647            .FirstOrDefaultAsync(x => x.PublicId == command.Id && !x.IsDeleted);
 48
 1649        if (scope is null)
 50        {
 451            return output.WithError(ScopeMessages.ScopeNotFound);
 52        }
 53
 54        // Step 4 (AF-03b): the new name must not collide with another scope, compared
 55        // case-insensitively (LOWER() in SQL) so the app layer authoritatively rejects case variants
 56        // that the case-sensitive DB unique index on Name would not catch. Checked against all scopes
 57        // (deleted included), excluding this scope so an unchanged name is not a false conflict.
 1258        var nameTaken = await scopeReader.Query()
 1259            .AnyAsync(x => x.Name.ToLower() == command.Name.ToLower() && x.PublicId != command.Id);
 60
 1261        if (nameTaken)
 62        {
 363            return output.WithError(ScopeMessages.NameAlreadyExists);
 64        }
 65
 66        // Owner PublicIds for the response. A projection over the navigation (no Include needed),
 67        // which EF translates to a join and the in-memory fake evaluates directly.
 968        var ownerIds = await scopeReader.Query()
 969            .Where(x => x.PublicId == command.Id)
 970            .SelectMany(x => x.Owners.Select(owner => owner.Person.PublicId))
 971            .ToListAsync();
 72
 73        // Step 4 (main flow): apply the updates and stamp UpdatedAt (no DB trigger maintains it) — those
 74        // columns only, and only while the scope is not deleted (#205). A whole-row save of the copy read
 75        // above would write the settings back as read, over a change their own endpoints made meanwhile,
 76        // and the deletion flag with them.
 977        var update = await atomicWrites.TryUpdateScopeDetailsAsync(scope, command.Name, command.Description,
 978            command.DefaultLegalBasis, command.PrivacyNoticeUri, DateTime.UtcNow);
 79
 80        switch (update)
 81        {
 82            // AF-03a: deleted since the read above.
 83            case ScopeUpdateOutcome.NotFound:
 284                return output.WithError(ScopeMessages.ScopeNotFound);
 85            // AF-03b: the unique index on the name, for a name another scope took since the check above.
 86            case ScopeUpdateOutcome.NameTaken:
 187                return output.WithError(ScopeMessages.NameAlreadyExists);
 88        }
 89
 90        // Step 5: return the updated scope.
 691        return output
 692            .WithData(new UpdateScopeCommandOutput
 693            {
 694                Id = scope.PublicId,
 695                Name = scope.Name,
 696                Description = scope.Description,
 697                GoogleSignInEnabled = scope.GoogleSignInEnabled,
 698                SelfRegistrationEnabled = scope.SelfRegistrationEnabled,
 699                EmailVerificationUrl = scope.EmailVerificationUrl,
 6100                PasswordResetUrl = scope.PasswordResetUrl,
 6101                OwnerIds = ownerIds,
 6102                CreatedAt = scope.CreatedAt,
 6103                UpdatedAt = scope.UpdatedAt
 6104            })
 6105            .WithMessage(ScopeMessages.ScopeUpdatedSuccessfully);
 18106    }
 107}