feat(auth): implement account activation backend

Add complete backend support for student account activation:

Persistence layer:
- StudentConfiguration: add IsActive, ActivationCode, ActivationCodeExpiry mappings
- Migration: AddStudentActivation with new columns
- StudentRepository: implement GetByActivationCodeAsync

Application layer:
- ActivateAccountCommand: validates code and activates student account
- RegenerateActivationCodeCommand: generates new code with expiry
- CreateStudentCommand: generates activation code on registration
- StudentDto: expose activation status fields
- Auth Queries: add activation status lookup

API layer:
- Mutation: add activateAccount and regenerateActivationCode endpoints
- Query: add activation status queries

Tests:
- Unit tests for activation commands
- Integration tests for enrollment flow with activation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-09 07:42:05 -05:00
parent 0d9c3d46ca
commit 847b494a71
20 changed files with 1581 additions and 16 deletions

View File

@ -35,6 +35,14 @@ public class StudentConfiguration : IEntityTypeConfiguration<Student>
// Use raw column name for index to avoid value object issues // Use raw column name for index to avoid value object issues
builder.HasIndex("Email").IsUnique(); builder.HasIndex("Email").IsUnique();
// Activation fields
builder.Property(s => s.ActivationCodeHash)
.HasMaxLength(256);
builder.Property(s => s.ActivationExpiresAt);
builder.Ignore(s => s.IsActivated);
builder.HasMany(s => s.Enrollments) builder.HasMany(s => s.Enrollments)
.WithOne(e => e.Student) .WithOne(e => e.Student)
.HasForeignKey(e => e.StudentId) .HasForeignKey(e => e.StudentId)

View File

@ -0,0 +1,337 @@
// <auto-generated />
using System;
using Adapters.Driven.Persistence.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Adapters.Driven.Persistence.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260109055746_AddStudentActivation")]
partial class AddStudentActivation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Domain.Entities.Enrollment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime2");
b.Property<int>("StudentId")
.HasColumnType("int");
b.Property<int>("SubjectId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("SubjectId");
b.HasIndex("StudentId", "SubjectId")
.IsUnique();
b.ToTable("Enrollments", (string)null);
});
modelBuilder.Entity("Domain.Entities.Professor", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.ToTable("Professors", (string)null);
b.HasData(
new
{
Id = 1,
Name = "Dr. García"
},
new
{
Id = 2,
Name = "Dra. Martínez"
},
new
{
Id = 3,
Name = "Dr. López"
},
new
{
Id = 4,
Name = "Dra. Rodríguez"
},
new
{
Id = 5,
Name = "Dr. Hernández"
});
});
modelBuilder.Entity("Domain.Entities.Student", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ActivationCodeHash")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime?>("ActivationExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Students", (string)null);
});
modelBuilder.Entity("Domain.Entities.Subject", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Credits")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(3);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("ProfessorId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ProfessorId");
b.ToTable("Subjects", (string)null);
b.HasData(
new
{
Id = 1,
Credits = 3,
Name = "Matemáticas I",
ProfessorId = 1
},
new
{
Id = 2,
Credits = 3,
Name = "Matemáticas II",
ProfessorId = 1
},
new
{
Id = 3,
Credits = 3,
Name = "Física I",
ProfessorId = 2
},
new
{
Id = 4,
Credits = 3,
Name = "Física II",
ProfessorId = 2
},
new
{
Id = 5,
Credits = 3,
Name = "Programación I",
ProfessorId = 3
},
new
{
Id = 6,
Credits = 3,
Name = "Programación II",
ProfessorId = 3
},
new
{
Id = 7,
Credits = 3,
Name = "Base de Datos I",
ProfessorId = 4
},
new
{
Id = 8,
Credits = 3,
Name = "Base de Datos II",
ProfessorId = 4
},
new
{
Id = 9,
Credits = 3,
Name = "Redes I",
ProfessorId = 5
},
new
{
Id = 10,
Credits = 3,
Name = "Redes II",
ProfessorId = 5
});
});
modelBuilder.Entity("Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("RecoveryCodeHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("StudentId")
.HasColumnType("int");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.HasIndex("StudentId");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Domain.Entities.Enrollment", b =>
{
b.HasOne("Domain.Entities.Student", "Student")
.WithMany("Enrollments")
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Domain.Entities.Subject", "Subject")
.WithMany("Enrollments")
.HasForeignKey("SubjectId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Student");
b.Navigation("Subject");
});
modelBuilder.Entity("Domain.Entities.Subject", b =>
{
b.HasOne("Domain.Entities.Professor", "Professor")
.WithMany("Subjects")
.HasForeignKey("ProfessorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Professor");
});
modelBuilder.Entity("Domain.Entities.User", b =>
{
b.HasOne("Domain.Entities.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Student");
});
modelBuilder.Entity("Domain.Entities.Professor", b =>
{
b.Navigation("Subjects");
});
modelBuilder.Entity("Domain.Entities.Student", b =>
{
b.Navigation("Enrollments");
});
modelBuilder.Entity("Domain.Entities.Subject", b =>
{
b.Navigation("Enrollments");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Adapters.Driven.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStudentActivation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ActivationCodeHash",
table: "Students",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ActivationExpiresAt",
table: "Students",
type: "datetime2",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ActivationCodeHash",
table: "Students");
migrationBuilder.DropColumn(
name: "ActivationExpiresAt",
table: "Students");
}
}
}

View File

@ -102,6 +102,13 @@ namespace Adapters.Driven.Persistence.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ActivationCodeHash")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime?>("ActivationExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Email") b.Property<string>("Email")
.IsRequired() .IsRequired()
.HasMaxLength(150) .HasMaxLength(150)

View File

@ -115,6 +115,12 @@ public class StudentRepository(AppDbContext context) : IStudentRepository
return (resultItems, nextCursor, totalCount); return (resultItems, nextCursor, totalCount);
} }
public async Task<IReadOnlyList<Student>> GetPendingActivationAsync(CancellationToken ct = default) =>
await context.Students
.Where(s => s.ActivationCodeHash != null && s.ActivationExpiresAt > DateTime.UtcNow)
.AsNoTracking()
.ToListAsync(ct);
public void Add(Student student) => context.Students.Add(student); public void Add(Student student) => context.Students.Add(student);
public void Update(Student student) => context.Students.Update(student); public void Update(Student student) => context.Students.Update(student);
public void Delete(Student student) => context.Students.Remove(student); public void Delete(Student student) => context.Students.Remove(student);

View File

@ -1,5 +1,6 @@
namespace Adapters.Driving.Api.Types; namespace Adapters.Driving.Api.Types;
using Application.Auth.Commands;
using Application.Enrollments.Commands; using Application.Enrollments.Commands;
using Application.Students.Commands; using Application.Students.Commands;
using Application.Students.DTOs; using Application.Students.DTOs;
@ -13,15 +14,37 @@ using System.Security.Claims;
/// </summary> /// </summary>
public class Mutation public class Mutation
{ {
[Authorize] [Authorize(Roles = ["Admin"])]
[GraphQLDescription("Create a new student (requires authentication)")] [GraphQLDescription("Create a new student with activation code (admin only)")]
public async Task<CreateStudentPayload> CreateStudent( public async Task<CreateStudentWithActivationPayload> CreateStudent(
CreateStudentInput input, CreateStudentInput input,
[Service] IMediator mediator, [Service] IMediator mediator,
CancellationToken ct) CancellationToken ct)
{ {
var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), ct); var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), ct);
return new CreateStudentPayload(result); return new CreateStudentWithActivationPayload(result);
}
[Authorize(Roles = ["Admin"])]
[GraphQLDescription("Regenerate activation code for a student (admin only)")]
public async Task<CreateStudentWithActivationPayload?> RegenerateActivationCode(
int studentId,
[Service] IMediator mediator,
CancellationToken ct)
{
var result = await mediator.Send(new RegenerateActivationCodeCommand(studentId), ct);
return result != null ? new CreateStudentWithActivationPayload(result) : null;
}
[GraphQLDescription("Activate a student account using activation code (public)")]
public async Task<ActivateAccountPayload> ActivateAccount(
ActivateAccountInput input,
[Service] IMediator mediator,
CancellationToken ct)
{
var result = await mediator.Send(
new ActivateAccountCommand(input.ActivationCode, input.Username, input.Password), ct);
return new ActivateAccountPayload(result.Success, result.Token, result.RecoveryCode, result.Error);
} }
[Authorize] [Authorize]
@ -108,6 +131,7 @@ public class Mutation
public record CreateStudentInput(string Name, string Email); public record CreateStudentInput(string Name, string Email);
public record UpdateStudentInput(string Name, string Email); public record UpdateStudentInput(string Name, string Email);
public record EnrollStudentInput(int StudentId, int SubjectId); public record EnrollStudentInput(int StudentId, int SubjectId);
public record ActivateAccountInput(string ActivationCode, string Username, string Password);
// Error Types // Error Types
/// <summary> /// <summary>
@ -171,3 +195,25 @@ public record UnenrollStudentPayload(
{ {
public UnenrollStudentPayload(bool success) : this(success, null) { } public UnenrollStudentPayload(bool success) : this(success, null) { }
} }
/// <summary>
/// Payload for CreateStudent mutation with activation code.
/// </summary>
public record CreateStudentWithActivationPayload(
StudentDto Student,
string ActivationCode,
string ActivationUrl,
DateTime ExpiresAt)
{
public CreateStudentWithActivationPayload(CreateStudentResult result)
: this(result.Student, result.ActivationCode, result.ActivationUrl, result.ExpiresAt) { }
}
/// <summary>
/// Payload for ActivateAccount mutation.
/// </summary>
public record ActivateAccountPayload(
bool Success,
string? Token,
string? RecoveryCode,
string? Error);

View File

@ -1,5 +1,8 @@
namespace Adapters.Driving.Api.Types; namespace Adapters.Driving.Api.Types;
using Application.Admin.DTOs;
using Application.Admin.Queries;
using Application.Auth.Queries;
using Application.Enrollments.DTOs; using Application.Enrollments.DTOs;
using Application.Enrollments.Queries; using Application.Enrollments.Queries;
using Application.Professors.DTOs; using Application.Professors.DTOs;
@ -8,6 +11,7 @@ using Application.Students.DTOs;
using Application.Students.Queries; using Application.Students.Queries;
using Application.Subjects.DTOs; using Application.Subjects.DTOs;
using Application.Subjects.Queries; using Application.Subjects.Queries;
using HotChocolate.Authorization;
using MediatR; using MediatR;
public class Query public class Query
@ -58,4 +62,18 @@ public class Query
[Service] IMediator mediator, [Service] IMediator mediator,
CancellationToken ct) => CancellationToken ct) =>
await mediator.Send(new GetClassmatesQuery(studentId), ct); await mediator.Send(new GetClassmatesQuery(studentId), ct);
[Authorize(Roles = ["Admin"])]
[GraphQLDescription("Get admin statistics (requires Admin role)")]
public async Task<AdminStatsDto> GetAdminStats(
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new GetAdminStatsQuery(), ct);
[GraphQLDescription("Validate an activation code (public)")]
public async Task<ActivationValidationResult> ValidateActivationCode(
string code,
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new ValidateActivationCodeQuery(code), ct);
} }

View File

@ -14,6 +14,7 @@
<PackageReference Include="FluentValidation" Version="*" /> <PackageReference Include="FluentValidation" Version="*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" />
<PackageReference Include="MediatR" Version="*" /> <PackageReference Include="MediatR" Version="*" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,85 @@
using System.Security.Cryptography;
using Application.Students.DTOs;
using Domain.Entities;
using Domain.Ports.Repositories;
using MediatR;
namespace Application.Auth.Commands;
public record ActivateAccountCommand(
string ActivationCode,
string Username,
string Password
) : IRequest<ActivateAccountResult>;
public class ActivateAccountHandler(
IStudentRepository studentRepository,
IUserRepository userRepository,
IPasswordService passwordService,
IJwtService jwtService,
IUnitOfWork unitOfWork
) : IRequestHandler<ActivateAccountCommand, ActivateAccountResult>
{
public async Task<ActivateAccountResult> Handle(ActivateAccountCommand request, CancellationToken ct)
{
// Find student with matching activation code
var students = await studentRepository.GetPendingActivationAsync(ct);
var student = students.FirstOrDefault(s =>
s.ActivationCodeHash != null &&
passwordService.VerifyPassword(request.ActivationCode, s.ActivationCodeHash));
if (student == null)
return new ActivateAccountResult(false, null, null, "Codigo de activacion invalido o expirado");
if (student.IsActivationExpired())
return new ActivateAccountResult(false, null, null, "El codigo de activacion ha expirado");
// Check if username already exists
if (await userRepository.ExistsAsync(request.Username, ct))
return new ActivateAccountResult(false, null, null, "El nombre de usuario ya existe");
// Validate password
if (request.Password.Length < 6)
return new ActivateAccountResult(false, null, null, "La contrasena debe tener al menos 6 caracteres");
// Generate recovery code
var recoveryCode = GenerateRecoveryCode();
var recoveryCodeHash = passwordService.HashPassword(recoveryCode);
// Create user account
var passwordHash = passwordService.HashPassword(request.Password);
var user = User.Create(
request.Username,
passwordHash,
recoveryCodeHash,
UserRoles.Student,
student.Id
);
await userRepository.AddAsync(user, ct);
// Clear activation code (mark as activated)
// Need to re-fetch with tracking
var trackedStudent = await studentRepository.GetByIdAsync(student.Id, ct);
trackedStudent?.ClearActivationCode();
await unitOfWork.SaveChangesAsync(ct);
// Generate JWT token for auto-login
var token = jwtService.GenerateToken(user);
return new ActivateAccountResult(
Success: true,
Token: token,
RecoveryCode: recoveryCode,
Error: null
);
}
private static string GenerateRecoveryCode()
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var bytes = RandomNumberGenerator.GetBytes(12);
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
}
}

View File

@ -0,0 +1,30 @@
using Application.Students.DTOs;
using Domain.Ports.Repositories;
using MediatR;
namespace Application.Auth.Queries;
public record ValidateActivationCodeQuery(string Code) : IRequest<ActivationValidationResult>;
public class ValidateActivationCodeHandler(
IStudentRepository studentRepository,
IPasswordService passwordService
) : IRequestHandler<ValidateActivationCodeQuery, ActivationValidationResult>
{
public async Task<ActivationValidationResult> Handle(ValidateActivationCodeQuery request, CancellationToken ct)
{
var students = await studentRepository.GetPendingActivationAsync(ct);
var student = students.FirstOrDefault(s =>
s.ActivationCodeHash != null &&
passwordService.VerifyPassword(request.Code, s.ActivationCodeHash));
if (student == null)
return new ActivationValidationResult(false, null, "Codigo de activacion invalido");
if (student.IsActivationExpired())
return new ActivationValidationResult(false, null, "El codigo de activacion ha expirado");
return new ActivationValidationResult(true, student.Name, null);
}
}

View File

@ -1,26 +1,55 @@
namespace Application.Students.Commands; namespace Application.Students.Commands;
using System.Security.Cryptography;
using Application.Auth;
using Application.Students.DTOs; using Application.Students.DTOs;
using Domain.Entities; using Domain.Entities;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using Domain.ValueObjects; using Domain.ValueObjects;
using MediatR; using MediatR;
using Microsoft.Extensions.Configuration;
public record CreateStudentCommand(string Name, string Email) : IRequest<StudentDto>; public record CreateStudentCommand(string Name, string Email, string? BaseUrl = null)
: IRequest<CreateStudentResult>;
public class CreateStudentHandler( public class CreateStudentHandler(
IStudentRepository studentRepository, IStudentRepository studentRepository,
IUnitOfWork unitOfWork) IPasswordService passwordService,
: IRequestHandler<CreateStudentCommand, StudentDto> IUnitOfWork unitOfWork,
IConfiguration configuration)
: IRequestHandler<CreateStudentCommand, CreateStudentResult>
{ {
public async Task<StudentDto> Handle(CreateStudentCommand request, CancellationToken ct) private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48);
public async Task<CreateStudentResult> Handle(CreateStudentCommand request, CancellationToken ct)
{ {
var email = Email.Create(request.Email); var email = Email.Create(request.Email);
var student = new Student(request.Name, email); var student = new Student(request.Name, email);
// Generate activation code
var activationCode = GenerateActivationCode();
var codeHash = passwordService.HashPassword(activationCode);
student.SetActivationCode(codeHash, ActivationExpiration);
studentRepository.Add(student); studentRepository.Add(student);
await unitOfWork.SaveChangesAsync(ct); await unitOfWork.SaveChangesAsync(ct);
return new StudentDto(student.Id, student.Name, student.Email.Value, 0, []); var baseUrl = request.BaseUrl ?? configuration["App:BaseUrl"] ?? "http://localhost:4200";
var activationUrl = $"{baseUrl}/activate?code={activationCode}";
var studentDto = new StudentDto(student.Id, student.Name, student.Email.Value, 0, []);
return new CreateStudentResult(
studentDto,
activationCode,
activationUrl,
student.ActivationExpiresAt!.Value);
}
private static string GenerateActivationCode()
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var bytes = RandomNumberGenerator.GetBytes(12);
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
} }
} }

View File

@ -0,0 +1,59 @@
using System.Security.Cryptography;
using Application.Auth;
using Application.Students.DTOs;
using Domain.Ports.Repositories;
using MediatR;
using Microsoft.Extensions.Configuration;
namespace Application.Students.Commands;
public record RegenerateActivationCodeCommand(int StudentId, string? BaseUrl = null)
: IRequest<CreateStudentResult?>;
public class RegenerateActivationCodeHandler(
IStudentRepository studentRepository,
IPasswordService passwordService,
IUnitOfWork unitOfWork,
IConfiguration configuration
) : IRequestHandler<RegenerateActivationCodeCommand, CreateStudentResult?>
{
private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48);
public async Task<CreateStudentResult?> Handle(RegenerateActivationCodeCommand request, CancellationToken ct)
{
var student = await studentRepository.GetByIdAsync(request.StudentId, ct);
if (student == null)
return null;
// Only regenerate if student is not yet activated
if (student.IsActivated)
return null;
// Generate new activation code
var activationCode = GenerateActivationCode();
var codeHash = passwordService.HashPassword(activationCode);
student.SetActivationCode(codeHash, ActivationExpiration);
studentRepository.Update(student);
await unitOfWork.SaveChangesAsync(ct);
var baseUrl = request.BaseUrl ?? configuration["App:BaseUrl"] ?? "http://localhost:4200";
var activationUrl = $"{baseUrl}/activate?code={activationCode}";
var studentDto = new StudentDto(student.Id, student.Name, student.Email.Value, 0, []);
return new CreateStudentResult(
studentDto,
activationCode,
activationUrl,
student.ActivationExpiresAt!.Value);
}
private static string GenerateActivationCode()
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var bytes = RandomNumberGenerator.GetBytes(12);
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
}
}

View File

@ -31,3 +31,26 @@ public record StudentPagedDto(
string Name, string Name,
string Email, string Email,
int TotalCredits); int TotalCredits);
// Activation DTOs
public record CreateStudentResult(
StudentDto Student,
string ActivationCode,
string ActivationUrl,
DateTime ExpiresAt);
public record ActivationValidationResult(
bool IsValid,
string? StudentName,
string? Error);
public record ActivateAccountRequest(
string ActivationCode,
string Username,
string Password);
public record ActivateAccountResult(
bool Success,
string? Token,
string? RecoveryCode,
string? Error);

View File

@ -0,0 +1,278 @@
namespace Application.Tests.Auth;
using Application.Auth;
using Application.Auth.Commands;
using Domain.Entities;
using Domain.Ports.Repositories;
using Domain.ValueObjects;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class ActivateAccountCommandTests
{
private readonly IStudentRepository _studentRepository;
private readonly IUserRepository _userRepository;
private readonly IPasswordService _passwordService;
private readonly IJwtService _jwtService;
private readonly IUnitOfWork _unitOfWork;
private readonly ActivateAccountHandler _handler;
public ActivateAccountCommandTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_userRepository = Substitute.For<IUserRepository>();
_passwordService = Substitute.For<IPasswordService>();
_jwtService = Substitute.For<IJwtService>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_passwordService.HashPassword(Arg.Any<string>()).Returns("hashed_value");
_handler = new ActivateAccountHandler(
_studentRepository,
_userRepository,
_passwordService,
_jwtService,
_unitOfWork
);
}
[Fact]
public async Task Handle_WithValidActivationCode_ShouldActivateAccount()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
.Returns(student);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.Token.Should().Be("new.jwt.token");
result.RecoveryCode.Should().NotBeNullOrEmpty();
result.Error.Should().BeNull();
}
[Fact]
public async Task Handle_WithInvalidActivationCode_ShouldReturnError()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("INVALIDCODE", Arg.Any<string>())
.Returns(false);
var command = new ActivateAccountCommand("INVALIDCODE", "newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("invalido");
}
[Fact]
public async Task Handle_WithExpiredActivationCode_ShouldReturnError()
{
// Arrange
var student = CreateStudentWithExpiredActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("expirado");
}
[Fact]
public async Task Handle_WithExistingUsername_ShouldReturnError()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
_userRepository.ExistsAsync("existinguser", Arg.Any<CancellationToken>())
.Returns(true);
var command = new ActivateAccountCommand("VALIDCODE123", "existinguser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("ya existe");
}
[Fact]
public async Task Handle_WithShortPassword_ShouldReturnError()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "12345"); // 5 chars
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("al menos 6 caracteres");
}
[Fact]
public async Task Handle_ShouldCreateUserWithStudentRole()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
SetEntityId(student, 5);
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_studentRepository.GetByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(student);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
await _userRepository.Received(1).AddAsync(
Arg.Is<User>(u => u.Role == UserRoles.Student && u.StudentId == 5),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_ShouldGenerateRecoveryCode()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
.Returns(student);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.RecoveryCode.Should().NotBeNullOrEmpty();
result.RecoveryCode!.Length.Should().Be(12);
}
[Fact]
public async Task Handle_ShouldClearActivationCodeAfterSuccess()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
.Returns(student);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ShouldReturnJwtTokenForAutoLogin()
{
// Arrange
var student = CreateStudentWithActivationCode("John Doe", "john@test.com", "activation_hash");
_studentRepository.GetPendingActivationAsync(Arg.Any<CancellationToken>())
.Returns(new List<Student> { student });
_passwordService.VerifyPassword("VALIDCODE123", "activation_hash")
.Returns(true);
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_studentRepository.GetByIdAsync(student.Id, Arg.Any<CancellationToken>())
.Returns(student);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("auto.login.token");
var command = new ActivateAccountCommand("VALIDCODE123", "newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Token.Should().Be("auto.login.token");
_jwtService.Received(1).GenerateToken(Arg.Any<User>());
}
private static Student CreateStudentWithActivationCode(string name, string email, string activationHash)
{
var student = new Student(name, Email.Create(email));
typeof(Student).GetProperty("ActivationCodeHash")?.SetValue(student, activationHash);
typeof(Student).GetProperty("ActivationExpiresAt")?.SetValue(student, DateTime.UtcNow.AddDays(2));
return student;
}
private static Student CreateStudentWithExpiredActivationCode(string name, string email, string activationHash)
{
var student = new Student(name, Email.Create(email));
typeof(Student).GetProperty("ActivationCodeHash")?.SetValue(student, activationHash);
typeof(Student).GetProperty("ActivationExpiresAt")?.SetValue(student, DateTime.UtcNow.AddDays(-1));
return student;
}
private static void SetEntityId<T>(T entity, int id) where T : class
{
typeof(T).GetProperty("Id")?.SetValue(entity, id);
}
}

View File

@ -0,0 +1,171 @@
namespace Application.Tests.Auth;
using Application.Auth;
using Application.Auth.Commands;
using Domain.Entities;
using Domain.Ports.Repositories;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class LoginCommandTests
{
private readonly IUserRepository _userRepository;
private readonly IPasswordService _passwordService;
private readonly IJwtService _jwtService;
private readonly LoginCommandHandler _handler;
public LoginCommandTests()
{
_userRepository = Substitute.For<IUserRepository>();
_passwordService = Substitute.For<IPasswordService>();
_jwtService = Substitute.For<IJwtService>();
_handler = new LoginCommandHandler(_userRepository, _passwordService, _jwtService);
}
[Fact]
public async Task Handle_WithValidCredentials_ShouldReturnSuccessWithToken()
{
// Arrange
var user = CreateUser("testuser", "hashed_password");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("password123", "hashed_password")
.Returns(true);
_jwtService.GenerateToken(user)
.Returns("valid.jwt.token");
var command = new LoginCommand("testuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Success.Should().BeTrue();
result.Token.Should().Be("valid.jwt.token");
result.User.Should().NotBeNull();
result.User!.Username.Should().Be("testuser");
result.Error.Should().BeNull();
}
[Fact]
public async Task Handle_WithInvalidUsername_ShouldReturnError()
{
// Arrange
_userRepository.GetByUsernameAsync("nonexistent", Arg.Any<CancellationToken>())
.Returns((User?)null);
var command = new LoginCommand("nonexistent", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Token.Should().BeNull();
result.Error.Should().Contain("incorrectos");
}
[Fact]
public async Task Handle_WithInvalidPassword_ShouldReturnError()
{
// Arrange
var user = CreateUser("testuser", "hashed_password");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("wrongpassword", "hashed_password")
.Returns(false);
var command = new LoginCommand("testuser", "wrongpassword");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Token.Should().BeNull();
result.Error.Should().Contain("incorrectos");
}
[Fact]
public async Task Handle_ShouldNormalizUsernameToLowercase()
{
// Arrange
var user = CreateUser("testuser", "hashed_password");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("password123", "hashed_password")
.Returns(true);
_jwtService.GenerateToken(user)
.Returns("valid.jwt.token");
var command = new LoginCommand("TESTUSER", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
await _userRepository.Received(1).GetByUsernameAsync("testuser", Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ShouldUpdateLastLoginOnSuccess()
{
// Arrange
var user = CreateUser("testuser", "hashed_password");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("password123", "hashed_password")
.Returns(true);
_jwtService.GenerateToken(user)
.Returns("valid.jwt.token");
var command = new LoginCommand("testuser", "password123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
await _userRepository.Received(1).UpdateAsync(user, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WithStudentUser_ShouldIncludeStudentInfo()
{
// Arrange
var user = CreateUserWithStudent("studentuser", "hashed_password", 1, "John Doe");
_userRepository.GetByUsernameAsync("studentuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("password123", "hashed_password")
.Returns(true);
_jwtService.GenerateToken(user)
.Returns("valid.jwt.token");
var command = new LoginCommand("studentuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.User!.StudentId.Should().Be(1);
result.User.StudentName.Should().Be("John Doe");
result.User.Role.Should().Be(UserRoles.Student);
}
private static User CreateUser(string username, string passwordHash)
{
return User.Create(username, passwordHash, "recovery_hash", UserRoles.Student);
}
private static User CreateUserWithStudent(string username, string passwordHash, int studentId, string studentName)
{
var user = User.Create(username, passwordHash, "recovery_hash", UserRoles.Student, studentId);
// Set up the Student navigation property
var student = new Student(studentName, Domain.ValueObjects.Email.Create($"{username}@test.com"));
typeof(User).GetProperty("Student")?.SetValue(user, student);
return user;
}
}

View File

@ -0,0 +1,219 @@
namespace Application.Tests.Auth;
using Application.Auth;
using Application.Auth.Commands;
using Domain.Entities;
using Domain.Ports.Repositories;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class RegisterCommandTests
{
private readonly IUserRepository _userRepository;
private readonly IStudentRepository _studentRepository;
private readonly IPasswordService _passwordService;
private readonly IJwtService _jwtService;
private readonly IUnitOfWork _unitOfWork;
private readonly RegisterCommandHandler _handler;
public RegisterCommandTests()
{
_userRepository = Substitute.For<IUserRepository>();
_studentRepository = Substitute.For<IStudentRepository>();
_passwordService = Substitute.For<IPasswordService>();
_jwtService = Substitute.For<IJwtService>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_passwordService.HashPassword(Arg.Any<string>()).Returns("hashed_value");
_handler = new RegisterCommandHandler(
_userRepository,
_studentRepository,
_passwordService,
_jwtService,
_unitOfWork
);
}
[Fact]
public async Task Handle_WithValidData_ShouldCreateUser()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new RegisterCommand("newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.Token.Should().Be("new.jwt.token");
result.RecoveryCode.Should().NotBeNullOrEmpty();
result.Error.Should().BeNull();
}
[Fact]
public async Task Handle_WithExistingUsername_ShouldReturnError()
{
// Arrange
_userRepository.ExistsAsync("existinguser", Arg.Any<CancellationToken>())
.Returns(true);
var command = new RegisterCommand("existinguser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("ya existe");
}
[Fact]
public async Task Handle_WithShortPassword_ShouldReturnError()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
var command = new RegisterCommand("newuser", "12345"); // 5 chars, less than 6
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("al menos 6 caracteres");
}
[Fact]
public async Task Handle_WithNameAndEmail_ShouldCreateStudent()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new RegisterCommand(
"newuser",
"password123",
Name: "John Doe",
Email: "john@example.com"
);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
_studentRepository.Received(1).Add(Arg.Is<Student>(s => s.Name == "John Doe"));
await _unitOfWork.Received().SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WithInvalidEmail_ShouldReturnError()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
var command = new RegisterCommand(
"newuser",
"password123",
Name: "John Doe",
Email: "invalid-email"
);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Handle_ShouldGenerateRecoveryCode()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new RegisterCommand("newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.RecoveryCode.Should().NotBeNullOrEmpty();
result.RecoveryCode!.Length.Should().Be(12);
result.RecoveryCode.Should().MatchRegex("^[A-Z0-9]+$");
}
[Fact]
public async Task Handle_ShouldHashPasswordBeforeSaving()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new RegisterCommand("newuser", "password123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_passwordService.Received(1).HashPassword("password123");
await _userRepository.Received(1).AddAsync(
Arg.Is<User>(u => u.PasswordHash == "hashed_value"),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_ShouldNormalizeUsernameToLowercase()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new RegisterCommand("NEWUSER", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.User!.Username.Should().Be("newuser");
}
[Fact]
public async Task Handle_NewUserShouldHaveStudentRole()
{
// Arrange
_userRepository.ExistsAsync("newuser", Arg.Any<CancellationToken>())
.Returns(false);
_jwtService.GenerateToken(Arg.Any<User>())
.Returns("new.jwt.token");
var command = new RegisterCommand("newuser", "password123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.User!.Role.Should().Be(UserRoles.Student);
}
}

View File

@ -0,0 +1,185 @@
namespace Application.Tests.Auth;
using Application.Auth;
using Application.Auth.Commands;
using Domain.Entities;
using Domain.Ports.Repositories;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class ResetPasswordCommandTests
{
private readonly IUserRepository _userRepository;
private readonly IPasswordService _passwordService;
private readonly IUnitOfWork _unitOfWork;
private readonly ResetPasswordCommandHandler _handler;
public ResetPasswordCommandTests()
{
_userRepository = Substitute.For<IUserRepository>();
_passwordService = Substitute.For<IPasswordService>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_passwordService.HashPassword(Arg.Any<string>()).Returns("new_hashed_password");
_handler = new ResetPasswordCommandHandler(
_userRepository,
_passwordService,
_unitOfWork
);
}
[Fact]
public async Task Handle_WithValidRecoveryCode_ShouldResetPassword()
{
// Arrange
var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash")
.Returns(true);
var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.Error.Should().BeNull();
}
[Fact]
public async Task Handle_WithInvalidRecoveryCode_ShouldReturnError()
{
// Arrange
var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("INVALIDCODE", "recovery_code_hash")
.Returns(false);
var command = new ResetPasswordCommand("testuser", "INVALIDCODE", "newpassword123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("invalido");
}
[Fact]
public async Task Handle_WithNonExistentUser_ShouldReturnError()
{
// Arrange
_userRepository.GetByUsernameAsync("nonexistent", Arg.Any<CancellationToken>())
.Returns((User?)null);
var command = new ResetPasswordCommand("nonexistent", "ANYCODE123", "newpassword123");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("no encontrado");
}
[Fact]
public async Task Handle_WithShortNewPassword_ShouldReturnError()
{
// Arrange
var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "12345"); // 5 chars
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.Error.Should().Contain("al menos 6 caracteres");
}
[Fact]
public async Task Handle_ShouldHashNewPassword()
{
// Arrange
var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash")
.Returns(true);
var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_passwordService.Received(1).HashPassword("newpassword123");
}
[Fact]
public async Task Handle_ShouldSaveChangesOnSuccess()
{
// Arrange
var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash")
.Returns(true);
var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ShouldNotSaveChangesOnError()
{
// Arrange
_userRepository.GetByUsernameAsync("nonexistent", Arg.Any<CancellationToken>())
.Returns((User?)null);
var command = new ResetPasswordCommand("nonexistent", "ANYCODE123", "newpassword123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ShouldUpdateUserPasswordHash()
{
// Arrange
var user = CreateUser("testuser", "old_password_hash", "recovery_code_hash");
_userRepository.GetByUsernameAsync("testuser", Arg.Any<CancellationToken>())
.Returns(user);
_passwordService.VerifyPassword("VALIDCODE123", "recovery_code_hash")
.Returns(true);
var command = new ResetPasswordCommand("testuser", "VALIDCODE123", "newpassword123");
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
user.PasswordHash.Should().Be("new_hashed_password");
}
private static User CreateUser(string username, string passwordHash, string recoveryCodeHash)
{
return User.Create(username, passwordHash, recoveryCodeHash, UserRoles.Student);
}
}

View File

@ -1,5 +1,6 @@
namespace Application.Tests.Students; namespace Application.Tests.Students;
using Application.Auth;
using Application.Students.Commands; using Application.Students.Commands;
using Application.Students.DTOs; using Application.Students.DTOs;
using Domain.Entities; using Domain.Entities;
@ -7,20 +8,29 @@ using Domain.Exceptions;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using Domain.ValueObjects; using Domain.ValueObjects;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Configuration;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
public class CreateStudentCommandTests public class CreateStudentCommandTests
{ {
private readonly IStudentRepository _studentRepository; private readonly IStudentRepository _studentRepository;
private readonly IPasswordService _passwordService;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IConfiguration _configuration;
private readonly CreateStudentHandler _handler; private readonly CreateStudentHandler _handler;
public CreateStudentCommandTests() public CreateStudentCommandTests()
{ {
_studentRepository = Substitute.For<IStudentRepository>(); _studentRepository = Substitute.For<IStudentRepository>();
_passwordService = Substitute.For<IPasswordService>();
_unitOfWork = Substitute.For<IUnitOfWork>(); _unitOfWork = Substitute.For<IUnitOfWork>();
_handler = new CreateStudentHandler(_studentRepository, _unitOfWork); _configuration = Substitute.For<IConfiguration>();
_passwordService.HashPassword(Arg.Any<string>()).Returns("hashed_code");
_configuration["App:BaseUrl"].Returns("http://localhost:4200");
_handler = new CreateStudentHandler(_studentRepository, _passwordService, _unitOfWork, _configuration);
} }
[Fact] [Fact]
@ -34,10 +44,12 @@ public class CreateStudentCommandTests
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.Name.Should().Be("John Doe"); result.Student.Name.Should().Be("John Doe");
result.Email.Should().Be("john@example.com"); result.Student.Email.Should().Be("john@example.com");
result.TotalCredits.Should().Be(0); result.Student.TotalCredits.Should().Be(0);
result.Enrollments.Should().BeEmpty(); result.Student.Enrollments.Should().BeEmpty();
result.ActivationCode.Should().NotBeNullOrEmpty();
result.ActivationUrl.Should().Contain("/activate?code=");
_studentRepository.Received(1).Add(Arg.Any<Student>()); _studentRepository.Received(1).Add(Arg.Any<Student>());
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>()); await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());

View File

@ -2,6 +2,8 @@ namespace Integration.Tests;
using Adapters.Driven.Persistence.Context; using Adapters.Driven.Persistence.Context;
using Adapters.Driven.Persistence.Repositories; using Adapters.Driven.Persistence.Repositories;
using Adapters.Driven.Persistence.Services;
using Application.Auth;
using Application.Enrollments.Commands; using Application.Enrollments.Commands;
using Application.Students.Commands; using Application.Students.Commands;
using Domain.Entities; using Domain.Entities;
@ -9,6 +11,8 @@ using Domain.Exceptions;
using Domain.Services; using Domain.Services;
using FluentAssertions; using FluentAssertions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using NSubstitute;
public class EnrollmentFlowTests : IDisposable public class EnrollmentFlowTests : IDisposable
{ {
@ -55,7 +59,11 @@ public class EnrollmentFlowTests : IDisposable
public async Task CreateStudent_ShouldPersistToDatabase() public async Task CreateStudent_ShouldPersistToDatabase()
{ {
// Arrange // Arrange
var handler = new CreateStudentHandler(_studentRepository, _unitOfWork); var passwordService = new PasswordService();
var configuration = Substitute.For<IConfiguration>();
configuration["App:BaseUrl"].Returns("http://localhost:4200");
var handler = new CreateStudentHandler(_studentRepository, passwordService, _unitOfWork, configuration);
var command = new CreateStudentCommand("John Doe", "john@example.com"); var command = new CreateStudentCommand("John Doe", "john@example.com");
// Act // Act
@ -63,10 +71,12 @@ public class EnrollmentFlowTests : IDisposable
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.Name.Should().Be("John Doe"); result.Student.Name.Should().Be("John Doe");
result.ActivationCode.Should().NotBeNullOrEmpty();
var savedStudent = await _context.Students.FirstOrDefaultAsync(s => s.Email.Value == "john@example.com"); var savedStudent = await _context.Students.FirstOrDefaultAsync(s => s.Email.Value == "john@example.com");
savedStudent.Should().NotBeNull(); savedStudent.Should().NotBeNull();
savedStudent!.ActivationCodeHash.Should().NotBeNullOrEmpty();
} }
[Fact] [Fact]

View File

@ -14,6 +14,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="FluentAssertions" Version="*" /> <PackageReference Include="FluentAssertions" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="*" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="*" />
<PackageReference Include="NSubstitute" Version="*" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>