< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.CreateScopeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/CreateScopeCommandHandler.cs
Line coverage
97%
Covered lines: 45
Uncovered lines: 1
Coverable lines: 46
Total lines: 102
Line coverage: 97.8%
Branch coverage
90%
Covered branches: 9
Total branches: 10
Branch coverage: 90%
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()90%101097.36%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/CreateScopeCommandHandler.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;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="CreateScopeCommand" /> (UC-01): validates the request, verifies the scope
 16///     name is unique and every owner is an existing, non-logically-deleted administrator — a
 17///     <c>ScopeAdmin</c> or a <c>SystemAdmin</c> (FR-SC-08) — then
 18///     persists the scope together with a <c>SCOPE_OWNER</c> row for each owner. All failures are
 19///     returned as errors on the <see cref="DataOutput{T}" /> rather than thrown, using the canonical
 20///     <see cref="ScopeMessages" /> so the response resolver can pick the matching status code.
 21/// </summary>
 1522public class CreateScopeCommandHandler(
 1523    IValidator<CreateScopeCommand> validator,
 1524    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1525    IAsyncReadOnlyRepository<Person, long> personReader,
 1526    IAsyncRepository<Scope, long> scopeWriter)
 27    : ICommandHandlerAsync<CreateScopeCommand, CreateScopeCommandOutput>
 28{
 29    public async Task<DataOutput<CreateScopeCommandOutput?>> HandleAsync(CreateScopeCommand command, CancellationToken c
 30    {
 1531        var output = DataOutput<CreateScopeCommandOutput?>.New;
 32
 33        // Step 2 (AF-01b): validate input fields.
 1534        var validation = await validator.ValidateAsync(command);
 35
 1536        if (!validation.IsValid)
 37        {
 838            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 39        }
 40
 1141        var ownerIds = command.OwnerIds.Distinct().ToList();
 42
 43        // Step 3 (AF-01a): the scope name must be unique, compared case-insensitively (LOWER() in SQL).
 1144        var nameAlreadyExists = await scopeReader.Query()
 1145            .AnyAsync(x => x.Name.ToLower() == command.Name.ToLower());
 46
 1147        if (nameAlreadyExists)
 48        {
 449            output.AddError(ScopeMessages.NameAlreadyExists);
 50        }
 51
 52        // Step 4 (AF-01d): every owner must be an existing, non-logically-deleted administrator —
 53        // a ScopeAdmin, or a SystemAdmin, who may own a scope as well as govern it (FR-SC-08). The
 54        // roles are identified by their Roles enum values, which DatabaseSeeder writes as the role
 55        // rows' ids on every startup — the same way every other handler resolves a role.
 1156        var owners = await personReader.Query()
 1157            .Where(x => ownerIds.Contains(x.PublicId) && !x.IsDeleted &&
 1158                        (x.RoleId == (long)Roles.ScopeAdmin || x.RoleId == (long)Roles.SystemAdmin))
 1159            .ToListAsync();
 60
 1161        if (owners.Count != ownerIds.Count)
 62        {
 363            output.AddError(ScopeMessages.OwnerNotValidScopeAdmin);
 64        }
 65
 1166        if (!output.Success)
 67        {
 768            return output;
 69        }
 70
 71        // Step 5: create the scope with a SCOPE_OWNER row for each owner (inserted atomically).
 472        var scope = new Scope
 473        {
 474            Name = command.Name,
 475            Description = command.Description,
 476            Owners = [.. owners.Select(owner => new ScopeOwner { PersonId = owner.Id })]
 477        };
 78
 479        var creation = await scopeWriter.CreateAsync(scope);
 80
 481        if (!creation.Success)
 82        {
 083            return output.WithErrors(creation.Errors);
 84        }
 85
 86        // Step 6: return the created scope.
 487        return output
 488            .WithData(new CreateScopeCommandOutput
 489            {
 490                Id = scope.PublicId,
 491                Name = scope.Name,
 492                Description = scope.Description,
 493                GoogleSignInEnabled = scope.GoogleSignInEnabled,
 494                SelfRegistrationEnabled = scope.SelfRegistrationEnabled,
 495                EmailVerificationUrl = scope.EmailVerificationUrl,
 496                PasswordResetUrl = scope.PasswordResetUrl,
 497                OwnerIds = [.. owners.Select(owner => owner.PublicId)],
 498                CreatedAt = scope.CreatedAt
 499            })
 4100            .WithMessage(ScopeMessages.ScopeCreatedSuccessfully);
 15101    }
 102}