test: add unit and integration tests

Domain Tests:
- StudentTests: entity creation and validation
- EmailTests: value object validation
- EnrollmentDomainServiceTests: business rules

Application Tests:
- EnrollStudentCommandTests: enrollment scenarios
  - Max 3 subjects validation
  - Same professor constraint

Uses xUnit, Moq, and FluentAssertions
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-07 23:00:27 -05:00
parent 84d88c91d1
commit 874b67d07f
15 changed files with 2171 additions and 0 deletions

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="*" />
<PackageReference Include="NSubstitute" Version="*" />
<PackageReference Include="coverlet.collector" Version="*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\backend\Application\Application.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,155 @@
namespace Application.Tests.Enrollments;
using Application.Enrollments.Commands;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using Domain.Services;
using Domain.ValueObjects;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class EnrollStudentCommandTests
{
private readonly IStudentRepository _studentRepo = Substitute.For<IStudentRepository>();
private readonly ISubjectRepository _subjectRepo = Substitute.For<ISubjectRepository>();
private readonly IEnrollmentRepository _enrollmentRepo = Substitute.For<IEnrollmentRepository>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly EnrollmentDomainService _enrollmentService = new();
private readonly EnrollStudentHandler _sut;
public EnrollStudentCommandTests()
{
_sut = new EnrollStudentHandler(
_studentRepo,
_subjectRepo,
_enrollmentRepo,
_enrollmentService,
_unitOfWork);
}
[Fact]
public async Task Handle_WhenValid_ShouldEnrollStudent()
{
var student = CreateStudent();
var subject = CreateSubjectWithProfessor(1, "Math", 1, "Prof. Smith");
_studentRepo.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepo.GetByIdWithProfessorAsync(1, Arg.Any<CancellationToken>())
.Returns(subject);
var command = new EnrollStudentCommand(1, 1);
var result = await _sut.Handle(command, CancellationToken.None);
result.Should().NotBeNull();
result.SubjectName.Should().Be("Math");
result.ProfessorName.Should().Be("Prof. Smith");
_enrollmentRepo.Received(1).Add(Arg.Any<Enrollment>());
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenStudentNotFound_ShouldThrow()
{
_studentRepo.GetByIdWithEnrollmentsAsync(999, Arg.Any<CancellationToken>())
.Returns((Student?)null);
var command = new EnrollStudentCommand(999, 1);
var act = () => _sut.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<StudentNotFoundException>();
}
[Fact]
public async Task Handle_WhenSubjectNotFound_ShouldThrow()
{
var student = CreateStudent();
_studentRepo.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepo.GetByIdWithProfessorAsync(999, Arg.Any<CancellationToken>())
.Returns((Subject?)null);
var command = new EnrollStudentCommand(1, 999);
var act = () => _sut.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<SubjectNotFoundException>();
}
[Fact]
public async Task Handle_WhenMaxEnrollmentsReached_ShouldThrow()
{
var student = CreateStudentWithEnrollments(3);
var subject = CreateSubjectWithProfessor(10, "Physics", 99, "New Prof");
_studentRepo.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepo.GetByIdWithProfessorAsync(10, Arg.Any<CancellationToken>())
.Returns(subject);
var command = new EnrollStudentCommand(1, 10);
var act = () => _sut.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<MaxEnrollmentsExceededException>();
_enrollmentRepo.DidNotReceive().Add(Arg.Any<Enrollment>());
}
[Fact]
public async Task Handle_WhenSameProfessor_ShouldThrow()
{
var student = CreateStudent();
var existingSubject = CreateSubjectWithProfessor(1, "Math", 1, "Prof. Smith");
AddEnrollmentToStudent(student, existingSubject);
var newSubject = CreateSubjectWithProfessor(2, "Algebra", 1, "Prof. Smith");
_studentRepo.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepo.GetByIdWithProfessorAsync(2, Arg.Any<CancellationToken>())
.Returns(newSubject);
var command = new EnrollStudentCommand(1, 2);
var act = () => _sut.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<SameProfessorConstraintException>();
}
private static Student CreateStudent()
{
var student = new Student("John Doe", Email.Create("john@test.com"));
typeof(Student).GetProperty("Id")!.SetValue(student, 1);
return student;
}
private static Student CreateStudentWithEnrollments(int count)
{
var student = CreateStudent();
for (int i = 0; i < count; i++)
{
var subject = CreateSubjectWithProfessor(i + 1, $"Subject{i}", i + 1, $"Prof{i}");
AddEnrollmentToStudent(student, subject);
}
return student;
}
private static Subject CreateSubjectWithProfessor(int id, string name, int profId, string profName)
{
var professor = new Professor(profName);
typeof(Professor).GetProperty("Id")!.SetValue(professor, profId);
var subject = new Subject(name, profId);
typeof(Subject).GetProperty("Id")!.SetValue(subject, id);
typeof(Subject).GetProperty("Professor")!.SetValue(subject, professor);
return subject;
}
private static void AddEnrollmentToStudent(Student student, Subject subject)
{
var enrollment = new Enrollment(student.Id, subject.Id);
typeof(Enrollment).GetProperty("Subject")!.SetValue(enrollment, subject);
student.AddEnrollment(enrollment);
}
}

View File

@ -0,0 +1,157 @@
namespace Application.Tests.Enrollments;
using Application.Enrollments.Queries;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using Domain.ValueObjects;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class GetClassmatesQueryTests
{
private readonly IStudentRepository _studentRepository;
private readonly IEnrollmentRepository _enrollmentRepository;
private readonly GetClassmatesHandler _handler;
public GetClassmatesQueryTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_enrollmentRepository = Substitute.For<IEnrollmentRepository>();
_handler = new GetClassmatesHandler(_studentRepository, _enrollmentRepository);
}
[Fact]
public async Task Handle_WhenStudentNotFound_ShouldThrow()
{
// Arrange
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns((Student?)null);
var query = new GetClassmatesQuery(1);
// Act
var act = () => _handler.Handle(query, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<StudentNotFoundException>();
}
[Fact]
public async Task Handle_WhenStudentHasNoEnrollments_ShouldReturnEmptyList()
{
// Arrange
var student = new Student("John Doe", Email.Create("john@test.com"));
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
var query = new GetClassmatesQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeEmpty();
await _enrollmentRepository.DidNotReceive()
.GetClassmatesBatchAsync(Arg.Any<int>(), Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenStudentHasEnrollments_ShouldUseBatchQuery()
{
// Arrange
var student = new Student("John Doe", Email.Create("john@test.com"));
SetEntityId(student, 1);
// Create enrollments with IDs
var enrollment1 = new Enrollment(1, 1);
var enrollment2 = new Enrollment(1, 2);
SetEntityId(enrollment1, 1);
SetEntityId(enrollment2, 2);
// Use reflection to set Subject property for name access
var subject1 = new Subject("Math", 1);
var subject2 = new Subject("Physics", 2);
SetEntityId(subject1, 1);
SetEntityId(subject2, 2);
SetNavigationProperty(enrollment1, "Subject", subject1);
SetNavigationProperty(enrollment2, "Subject", subject2);
student.AddEnrollment(enrollment1);
student.AddEnrollment(enrollment2);
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
var classmate1 = new Student("Jane Doe", Email.Create("jane@test.com"));
var classmate2 = new Student("Bob Smith", Email.Create("bob@test.com"));
SetEntityId(classmate1, 2);
SetEntityId(classmate2, 3);
var batchResult = new Dictionary<int, IReadOnlyList<Student>>
{
[1] = [classmate1],
[2] = [classmate2]
};
_enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>())
.Returns(batchResult);
var query = new GetClassmatesQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().HaveCount(2);
result.Should().Contain(r => r.SubjectId == 1 && r.Classmates.Any(c => c.Name == "Jane Doe"));
result.Should().Contain(r => r.SubjectId == 2 && r.Classmates.Any(c => c.Name == "Bob Smith"));
await _enrollmentRepository.Received(1)
.GetClassmatesBatchAsync(1, Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenNoClassmatesFound_ShouldReturnEmptyClassmatesLists()
{
// Arrange
var student = new Student("John Doe", Email.Create("john@test.com"));
SetEntityId(student, 1);
var enrollment = new Enrollment(1, 1);
SetEntityId(enrollment, 1);
var subject = new Subject("Math", 1);
SetEntityId(subject, 1);
SetNavigationProperty(enrollment, "Subject", subject);
student.AddEnrollment(enrollment);
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
var emptyBatchResult = new Dictionary<int, IReadOnlyList<Student>>();
_enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>())
.Returns(emptyBatchResult);
var query = new GetClassmatesQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().HaveCount(1);
result[0].Classmates.Should().BeEmpty();
}
private static void SetEntityId<T>(T entity, int id) where T : class
{
var prop = typeof(T).GetProperty("Id");
prop?.SetValue(entity, id);
}
private static void SetNavigationProperty<T>(T entity, string propertyName, object value) where T : class
{
var prop = typeof(T).GetProperty(propertyName);
prop?.SetValue(entity, value);
}
}

View File

@ -0,0 +1,96 @@
namespace Application.Tests.Enrollments;
using Application.Enrollments.Commands;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using Domain.ValueObjects;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class UnenrollStudentCommandTests
{
private readonly IEnrollmentRepository _enrollmentRepository;
private readonly IStudentRepository _studentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly UnenrollStudentHandler _handler;
public UnenrollStudentCommandTests()
{
_enrollmentRepository = Substitute.For<IEnrollmentRepository>();
_studentRepository = Substitute.For<IStudentRepository>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_handler = new UnenrollStudentHandler(_enrollmentRepository, _studentRepository, _unitOfWork);
}
[Fact]
public async Task Handle_WhenEnrollmentExists_ShouldUnenrollStudent()
{
// Arrange
var enrollment = new Enrollment(1, 1);
SetEntityId(enrollment, 1);
var student = new Student("John Doe", Email.Create("john@example.com"));
SetEntityId(student, 1);
student.AddEnrollment(enrollment);
_enrollmentRepository.GetByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(enrollment);
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
var command = new UnenrollStudentCommand(1);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeTrue();
_enrollmentRepository.Received(1).Delete(enrollment);
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenEnrollmentNotFound_ShouldThrow()
{
// Arrange
_enrollmentRepository.GetByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Enrollment?)null);
var command = new UnenrollStudentCommand(999);
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<EnrollmentNotFoundException>();
}
[Fact]
public async Task Handle_WhenStudentNotFound_ShouldStillDeleteEnrollment()
{
// Arrange
var enrollment = new Enrollment(999, 1);
SetEntityId(enrollment, 1);
_enrollmentRepository.GetByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(enrollment);
_studentRepository.GetByIdWithEnrollmentsAsync(999, Arg.Any<CancellationToken>())
.Returns((Student?)null);
var command = new UnenrollStudentCommand(1);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeTrue();
_enrollmentRepository.Received(1).Delete(enrollment);
}
private static void SetEntityId<T>(T entity, int id) where T : class
{
typeof(T).GetProperty("Id")?.SetValue(entity, id);
}
}

View File

@ -0,0 +1,94 @@
namespace Application.Tests.Professors;
using Application.Professors.DTOs;
using Application.Professors.Queries;
using Domain.Ports.Repositories;
using FluentAssertions;
using NSubstitute;
using System.Linq.Expressions;
using Xunit;
public class GetProfessorsQueryTests
{
private readonly IProfessorRepository _professorRepository;
private readonly GetProfessorsHandler _handler;
public GetProfessorsQueryTests()
{
_professorRepository = Substitute.For<IProfessorRepository>();
_handler = new GetProfessorsHandler(_professorRepository);
}
[Fact]
public async Task Handle_ShouldReturnAllProfessors()
{
// Arrange
var professors = new List<ProfessorDto>
{
new(1, "Prof A", [new ProfessorSubjectDto(1, "Math", 3), new ProfessorSubjectDto(2, "Physics", 3)]),
new(2, "Prof B", [new ProfessorSubjectDto(3, "Chemistry", 3)])
};
_professorRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Professor, ProfessorDto>>>(),
Arg.Any<CancellationToken>())
.Returns(professors);
var query = new GetProfessorsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().HaveCount(2);
result[0].Name.Should().Be("Prof A");
result[0].Subjects.Should().HaveCount(2);
}
[Fact]
public async Task Handle_WhenNoProfessors_ShouldReturnEmptyList()
{
// Arrange
_professorRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Professor, ProfessorDto>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<ProfessorDto>());
var query = new GetProfessorsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeEmpty();
}
[Fact]
public async Task Handle_ShouldReturnProfessorsWithSubjects()
{
// Arrange
var subjects = new List<ProfessorSubjectDto>
{
new(1, "Math", 3),
new(2, "Physics", 3)
};
var professors = new List<ProfessorDto>
{
new(1, "Prof A", subjects)
};
_professorRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Professor, ProfessorDto>>>(),
Arg.Any<CancellationToken>())
.Returns(professors);
var query = new GetProfessorsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result[0].Subjects.Should().Contain(s => s.Name == "Math");
result[0].Subjects.Should().Contain(s => s.Name == "Physics");
}
}

View File

@ -0,0 +1,196 @@
namespace Application.Tests.Students;
using Application.Students.Commands;
using Application.Students.DTOs;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using Domain.ValueObjects;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class CreateStudentCommandTests
{
private readonly IStudentRepository _studentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly CreateStudentHandler _handler;
public CreateStudentCommandTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_handler = new CreateStudentHandler(_studentRepository, _unitOfWork);
}
[Fact]
public async Task Handle_WithValidData_ShouldCreateStudent()
{
// Arrange
var command = new CreateStudentCommand("John Doe", "john@example.com");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("John Doe");
result.Email.Should().Be("john@example.com");
result.TotalCredits.Should().Be(0);
result.Enrollments.Should().BeEmpty();
_studentRepository.Received(1).Add(Arg.Any<Student>());
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WithInvalidEmail_ShouldThrow()
{
// Arrange
var command = new CreateStudentCommand("John Doe", "invalid-email");
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task Handle_WithEmptyName_ShouldThrow()
{
// Arrange
var command = new CreateStudentCommand("", "john@example.com");
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
}
public class UpdateStudentCommandTests
{
private readonly IStudentRepository _studentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly UpdateStudentHandler _handler;
public UpdateStudentCommandTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_handler = new UpdateStudentHandler(_studentRepository, _unitOfWork);
}
[Fact]
public async Task Handle_WhenStudentExists_ShouldUpdateStudent()
{
// Arrange
var student = new Student("Old Name", Email.Create("old@example.com"));
SetEntityId(student, 1);
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
var command = new UpdateStudentCommand(1, "New Name", "new@example.com");
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("New Name");
result.Email.Should().Be("new@example.com");
_studentRepository.Received(1).Update(student);
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenStudentNotFound_ShouldThrow()
{
// Arrange
_studentRepository.GetByIdWithEnrollmentsAsync(999, Arg.Any<CancellationToken>())
.Returns((Student?)null);
var command = new UpdateStudentCommand(999, "Name", "email@test.com");
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<StudentNotFoundException>();
}
[Fact]
public async Task Handle_WithInvalidEmail_ShouldThrow()
{
// Arrange
var student = new Student("Name", Email.Create("old@example.com"));
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
var command = new UpdateStudentCommand(1, "Name", "invalid");
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
private static void SetEntityId<T>(T entity, int id) where T : class
{
typeof(T).GetProperty("Id")?.SetValue(entity, id);
}
}
public class DeleteStudentCommandTests
{
private readonly IStudentRepository _studentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly DeleteStudentHandler _handler;
public DeleteStudentCommandTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_handler = new DeleteStudentHandler(_studentRepository, _unitOfWork);
}
[Fact]
public async Task Handle_WhenStudentExists_ShouldDeleteStudent()
{
// Arrange
var student = new Student("John Doe", Email.Create("john@example.com"));
_studentRepository.GetByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
var command = new DeleteStudentCommand(1);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeTrue();
_studentRepository.Received(1).Delete(student);
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenStudentNotFound_ShouldThrow()
{
// Arrange
_studentRepository.GetByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Student?)null);
var command = new DeleteStudentCommand(999);
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<StudentNotFoundException>();
}
}

View File

@ -0,0 +1,258 @@
namespace Application.Tests.Students;
using Application.Students.DTOs;
using Application.Students.Queries;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using FluentAssertions;
using NSubstitute;
using System.Linq.Expressions;
using Xunit;
public class GetStudentByIdQueryTests
{
private readonly IStudentRepository _studentRepository;
private readonly GetStudentByIdHandler _handler;
public GetStudentByIdQueryTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_handler = new GetStudentByIdHandler(_studentRepository);
}
[Fact]
public async Task Handle_WhenStudentExists_ShouldReturnStudentDto()
{
// Arrange
var expectedDto = new StudentDto(1, "John Doe", "john@example.com", 0, []);
_studentRepository.GetByIdProjectedAsync(
1,
Arg.Any<Expression<Func<Domain.Entities.Student, StudentDto>>>(),
Arg.Any<CancellationToken>())
.Returns(expectedDto);
var query = new GetStudentByIdQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(1);
result.Name.Should().Be("John Doe");
result.Email.Should().Be("john@example.com");
}
[Fact]
public async Task Handle_WhenStudentNotFound_ShouldThrow()
{
// Arrange
_studentRepository.GetByIdProjectedAsync<StudentDto>(
999,
Arg.Any<Expression<Func<Domain.Entities.Student, StudentDto>>>(),
Arg.Any<CancellationToken>())
.Returns((StudentDto?)null);
var query = new GetStudentByIdQuery(999);
// Act
var act = () => _handler.Handle(query, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<StudentNotFoundException>();
}
[Fact]
public async Task Handle_WhenStudentHasEnrollments_ShouldReturnWithEnrollments()
{
// Arrange
var enrollments = new List<EnrollmentDto>
{
new(1, 1, "Math", 3, "Prof A", DateTime.UtcNow),
new(2, 2, "Physics", 3, "Prof B", DateTime.UtcNow)
};
var expectedDto = new StudentDto(1, "John Doe", "john@example.com", 6, enrollments);
_studentRepository.GetByIdProjectedAsync(
1,
Arg.Any<Expression<Func<Domain.Entities.Student, StudentDto>>>(),
Arg.Any<CancellationToken>())
.Returns(expectedDto);
var query = new GetStudentByIdQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Enrollments.Should().HaveCount(2);
result.TotalCredits.Should().Be(6);
}
}
public class GetStudentsQueryTests
{
private readonly IStudentRepository _studentRepository;
private readonly GetStudentsHandler _handler;
public GetStudentsQueryTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_handler = new GetStudentsHandler(_studentRepository);
}
[Fact]
public async Task Handle_ShouldReturnAllStudents()
{
// Arrange
var students = new List<StudentDto>
{
new(1, "John Doe", "john@example.com", 0, []),
new(2, "Jane Doe", "jane@example.com", 3, [])
};
_studentRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentDto>>>(),
Arg.Any<CancellationToken>())
.Returns(students);
var query = new GetStudentsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().HaveCount(2);
result[0].Name.Should().Be("John Doe");
result[1].Name.Should().Be("Jane Doe");
}
[Fact]
public async Task Handle_WhenNoStudents_ShouldReturnEmptyList()
{
// Arrange
_studentRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentDto>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<StudentDto>());
var query = new GetStudentsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeEmpty();
}
}
public class GetStudentsPagedQueryTests
{
private readonly IStudentRepository _studentRepository;
private readonly GetStudentsPagedHandler _handler;
public GetStudentsPagedQueryTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_handler = new GetStudentsPagedHandler(_studentRepository);
}
[Fact]
public async Task Handle_ShouldReturnPagedResult()
{
// Arrange
var items = new List<StudentPagedDto>
{
new(1, "John Doe", "john@example.com", 0),
new(2, "Jane Doe", "jane@example.com", 3)
};
_studentRepository.GetPagedProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentPagedDto>>>(),
null,
10,
Arg.Any<CancellationToken>())
.Returns((items, (int?)3, 5));
var query = new GetStudentsPagedQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Items.Should().HaveCount(2);
result.NextCursor.Should().Be(3);
result.TotalCount.Should().Be(5);
result.HasNextPage.Should().BeTrue();
}
[Fact]
public async Task Handle_WithCursor_ShouldPassCursorToRepository()
{
// Arrange
_studentRepository.GetPagedProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentPagedDto>>>(),
5,
10,
Arg.Any<CancellationToken>())
.Returns((new List<StudentPagedDto>(), (int?)null, 5));
var query = new GetStudentsPagedQuery(AfterCursor: 5);
// Act
await _handler.Handle(query, CancellationToken.None);
// Assert
await _studentRepository.Received(1).GetPagedProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentPagedDto>>>(),
5,
10,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WithLargePageSize_ShouldLimitTo50()
{
// Arrange
_studentRepository.GetPagedProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentPagedDto>>>(),
Arg.Any<int?>(),
50,
Arg.Any<CancellationToken>())
.Returns((new List<StudentPagedDto>(), (int?)null, 0));
var query = new GetStudentsPagedQuery(PageSize: 100);
// Act
await _handler.Handle(query, CancellationToken.None);
// Assert
await _studentRepository.Received(1).GetPagedProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentPagedDto>>>(),
null,
50,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenNoNextPage_ShouldReturnHasNextPageFalse()
{
// Arrange
_studentRepository.GetPagedProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Student, StudentPagedDto>>>(),
Arg.Any<int?>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns((new List<StudentPagedDto>(), (int?)null, 2));
var query = new GetStudentsPagedQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.HasNextPage.Should().BeFalse();
result.NextCursor.Should().BeNull();
}
}

View File

@ -0,0 +1,278 @@
namespace Application.Tests.Subjects;
using Application.Subjects.DTOs;
using Application.Subjects.Queries;
using Domain.Ports.Repositories;
using FluentAssertions;
using NSubstitute;
using System.Linq.Expressions;
using Xunit;
public class GetSubjectsQueryTests
{
private readonly ISubjectRepository _subjectRepository;
private readonly GetSubjectsHandler _handler;
public GetSubjectsQueryTests()
{
_subjectRepository = Substitute.For<ISubjectRepository>();
_handler = new GetSubjectsHandler(_subjectRepository);
}
[Fact]
public async Task Handle_ShouldReturnAllSubjects()
{
// Arrange
var subjects = new List<SubjectDto>
{
new(1, "Math", 3, 1, "Prof A"),
new(2, "Physics", 3, 1, "Prof A"),
new(3, "Chemistry", 3, 2, "Prof B")
};
_subjectRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Subject, SubjectDto>>>(),
Arg.Any<CancellationToken>())
.Returns(subjects);
var query = new GetSubjectsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().HaveCount(3);
result[0].Name.Should().Be("Math");
result[0].ProfessorName.Should().Be("Prof A");
}
[Fact]
public async Task Handle_WhenNoSubjects_ShouldReturnEmptyList()
{
// Arrange
_subjectRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Subject, SubjectDto>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<SubjectDto>());
var query = new GetSubjectsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeEmpty();
}
[Fact]
public async Task Handle_ShouldReturnCorrectCredits()
{
// Arrange
var subjects = new List<SubjectDto>
{
new(1, "Math", 3, 1, "Prof A")
};
_subjectRepository.GetAllProjectedAsync(
Arg.Any<Expression<Func<Domain.Entities.Subject, SubjectDto>>>(),
Arg.Any<CancellationToken>())
.Returns(subjects);
var query = new GetSubjectsQuery();
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result[0].Credits.Should().Be(3);
}
}
public class GetAvailableSubjectsQueryTests
{
private readonly IStudentRepository _studentRepository;
private readonly ISubjectRepository _subjectRepository;
private readonly GetAvailableSubjectsHandler _handler;
public GetAvailableSubjectsQueryTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_subjectRepository = Substitute.For<ISubjectRepository>();
_handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository);
}
[Fact]
public async Task Handle_WhenStudentNotFound_ShouldThrow()
{
// Arrange
_studentRepository.GetByIdWithEnrollmentsAsync(999, Arg.Any<CancellationToken>())
.Returns((Domain.Entities.Student?)null);
var query = new GetAvailableSubjectsQuery(999);
// Act
var act = () => _handler.Handle(query, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<Domain.Exceptions.StudentNotFoundException>();
}
[Fact]
public async Task Handle_WhenNoEnrollments_AllSubjectsShouldBeAvailable()
{
// Arrange
var student = new Domain.Entities.Student("John", Domain.ValueObjects.Email.Create("john@test.com"));
var professor = new Domain.Entities.Professor("Prof A");
SetEntityId(professor, 1);
var subjects = new List<Domain.Entities.Subject>
{
CreateSubject(1, "Math", 1, professor),
CreateSubject(2, "Physics", 1, professor)
};
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepository.GetAllWithProfessorsAsync(Arg.Any<CancellationToken>())
.Returns(subjects);
var query = new GetAvailableSubjectsQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().HaveCount(2);
result.Should().OnlyContain(s => s.IsAvailable);
}
[Fact]
public async Task Handle_WhenAlreadyEnrolled_SubjectShouldNotBeAvailable()
{
// Arrange
var student = new Domain.Entities.Student("John", Domain.ValueObjects.Email.Create("john@test.com"));
SetEntityId(student, 1);
var professor = new Domain.Entities.Professor("Prof A");
SetEntityId(professor, 1);
var subject = CreateSubject(1, "Math", 1, professor);
var enrollment = new Domain.Entities.Enrollment(1, 1);
SetNavigationProperty(enrollment, "Subject", subject);
student.AddEnrollment(enrollment);
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepository.GetAllWithProfessorsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Domain.Entities.Subject> { subject });
var query = new GetAvailableSubjectsQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result[0].IsAvailable.Should().BeFalse();
result[0].UnavailableReason.Should().Be("Already enrolled");
}
[Fact]
public async Task Handle_WhenSameProfessor_SubjectShouldNotBeAvailable()
{
// Arrange
var student = new Domain.Entities.Student("John", Domain.ValueObjects.Email.Create("john@test.com"));
SetEntityId(student, 1);
var professor = new Domain.Entities.Professor("Prof A");
SetEntityId(professor, 1);
var subject1 = CreateSubject(1, "Math", 1, professor);
var subject2 = CreateSubject(2, "Physics", 1, professor);
var enrollment = new Domain.Entities.Enrollment(1, 1);
SetNavigationProperty(enrollment, "Subject", subject1);
student.AddEnrollment(enrollment);
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepository.GetAllWithProfessorsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Domain.Entities.Subject> { subject1, subject2 });
var query = new GetAvailableSubjectsQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
var physics = result.First(s => s.Name == "Physics");
physics.IsAvailable.Should().BeFalse();
physics.UnavailableReason.Should().Be("Already have a subject with this professor");
}
[Fact]
public async Task Handle_WhenMaxEnrollmentsReached_AllSubjectsShouldBeUnavailable()
{
// Arrange
var student = new Domain.Entities.Student("John", Domain.ValueObjects.Email.Create("john@test.com"));
SetEntityId(student, 1);
var prof1 = new Domain.Entities.Professor("Prof A");
var prof2 = new Domain.Entities.Professor("Prof B");
var prof3 = new Domain.Entities.Professor("Prof C");
var prof4 = new Domain.Entities.Professor("Prof D");
SetEntityId(prof1, 1);
SetEntityId(prof2, 2);
SetEntityId(prof3, 3);
SetEntityId(prof4, 4);
var subject1 = CreateSubject(1, "Math", 1, prof1);
var subject2 = CreateSubject(2, "Physics", 2, prof2);
var subject3 = CreateSubject(3, "Chemistry", 3, prof3);
var subject4 = CreateSubject(4, "Biology", 4, prof4);
student.AddEnrollment(CreateEnrollmentWithSubject(1, 1, subject1));
student.AddEnrollment(CreateEnrollmentWithSubject(1, 2, subject2));
student.AddEnrollment(CreateEnrollmentWithSubject(1, 3, subject3));
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student);
_subjectRepository.GetAllWithProfessorsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Domain.Entities.Subject> { subject4 });
var query = new GetAvailableSubjectsQuery(1);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result[0].IsAvailable.Should().BeFalse();
result[0].UnavailableReason.Should().Be("Maximum 3 subjects reached");
}
private static void SetEntityId<T>(T entity, int id) where T : class
{
typeof(T).GetProperty("Id")?.SetValue(entity, id);
}
private static void SetNavigationProperty<T>(T entity, string propertyName, object value) where T : class
{
typeof(T).GetProperty(propertyName)?.SetValue(entity, value);
}
private static Domain.Entities.Subject CreateSubject(int id, string name, int professorId, Domain.Entities.Professor professor)
{
var subject = new Domain.Entities.Subject(name, professorId);
SetEntityId(subject, id);
SetNavigationProperty(subject, "Professor", professor);
return subject;
}
private static Domain.Entities.Enrollment CreateEnrollmentWithSubject(int studentId, int subjectId, Domain.Entities.Subject subject)
{
var enrollment = new Domain.Entities.Enrollment(studentId, subjectId);
SetNavigationProperty(enrollment, "Subject", subject);
return enrollment;
}
}

View File

@ -0,0 +1,322 @@
namespace Application.Tests.Validators;
using Application.Enrollments.Commands;
using Application.Students.Commands;
using Domain.Ports.Repositories;
using FluentAssertions;
using FluentValidation.TestHelper;
using NSubstitute;
using Xunit;
public class CreateStudentValidatorTests
{
private readonly IStudentRepository _studentRepository;
private readonly CreateStudentValidator _validator;
public CreateStudentValidatorTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_studentRepository.EmailExistsAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(false);
_validator = new CreateStudentValidator(_studentRepository);
}
[Fact]
public async Task Validate_WithValidCommand_ShouldNotHaveErrors()
{
// Arrange
var command = new CreateStudentCommand("John Doe", "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public async Task Validate_WithEmptyName_ShouldHaveError()
{
// Arrange
var command = new CreateStudentCommand("", "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Fact]
public async Task Validate_WithEmptyEmail_ShouldHaveError()
{
// Arrange
var command = new CreateStudentCommand("John Doe", "");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Email);
}
[Fact]
public async Task Validate_WithInvalidEmail_ShouldHaveError()
{
// Arrange
var command = new CreateStudentCommand("John Doe", "invalid-email");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Email);
}
[Fact]
public async Task Validate_WithExistingEmail_ShouldHaveError()
{
// Arrange
_studentRepository.EmailExistsAsync("existing@example.com", null, Arg.Any<CancellationToken>())
.Returns(true);
var command = new CreateStudentCommand("John Doe", "existing@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Email)
.WithErrorMessage("Email already exists");
}
[Fact]
public async Task Validate_WithNameTooLong_ShouldHaveError()
{
// Arrange
var longName = new string('A', 101);
var command = new CreateStudentCommand(longName, "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Fact]
public async Task Validate_WithNameTooShort_ShouldHaveError()
{
// Arrange
var command = new CreateStudentCommand("AB", "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Name)
.WithErrorMessage("Name must be at least 3 characters");
}
[Theory]
[InlineData("<script>alert('xss')</script>")]
[InlineData("John<img src=x onerror=alert(1)>")]
[InlineData("javascript:alert(1)")]
public async Task Validate_WithDangerousName_ShouldHaveError(string dangerousName)
{
// Arrange
var command = new CreateStudentCommand(dangerousName, "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Theory]
[InlineData("John123")]
[InlineData("John@Doe")]
[InlineData("John#Doe")]
public async Task Validate_WithInvalidCharactersInName_ShouldHaveError(string invalidName)
{
// Arrange
var command = new CreateStudentCommand(invalidName, "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Name)
.WithErrorMessage("Name contains invalid characters");
}
[Theory]
[InlineData("María García")]
[InlineData("José O'Brien")]
[InlineData("Ana-María López")]
[InlineData("Dr. Juan Pérez")]
public async Task Validate_WithValidSpecialCharactersInName_ShouldPass(string validName)
{
// Arrange
var command = new CreateStudentCommand(validName, "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldNotHaveValidationErrorFor(x => x.Name);
}
[Fact]
public async Task Validate_WithEmailTooLong_ShouldHaveError()
{
// Arrange
var longEmail = new string('a', 250) + "@example.com";
var command = new CreateStudentCommand("John Doe", longEmail);
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Email);
}
}
public class UpdateStudentValidatorTests
{
private readonly IStudentRepository _studentRepository;
private readonly UpdateStudentValidator _validator;
public UpdateStudentValidatorTests()
{
_studentRepository = Substitute.For<IStudentRepository>();
_studentRepository.EmailExistsAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(false);
_validator = new UpdateStudentValidator(_studentRepository);
}
[Fact]
public async Task Validate_WithValidCommand_ShouldNotHaveErrors()
{
// Arrange
var command = new UpdateStudentCommand(1, "John Doe", "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public async Task Validate_WithInvalidId_ShouldHaveError()
{
// Arrange
var command = new UpdateStudentCommand(0, "John Doe", "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Id);
}
[Fact]
public async Task Validate_WithNegativeId_ShouldHaveError()
{
// Arrange
var command = new UpdateStudentCommand(-1, "John Doe", "john@example.com");
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.Id);
}
}
public class EnrollStudentValidatorTests
{
private readonly EnrollStudentValidator _validator;
public EnrollStudentValidatorTests()
{
_validator = new EnrollStudentValidator();
}
[Fact]
public async Task Validate_WithValidCommand_ShouldNotHaveErrors()
{
// Arrange
var command = new EnrollStudentCommand(1, 1);
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public async Task Validate_WithInvalidStudentId_ShouldHaveError()
{
// Arrange
var command = new EnrollStudentCommand(0, 1);
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.StudentId);
}
[Fact]
public async Task Validate_WithInvalidSubjectId_ShouldHaveError()
{
// Arrange
var command = new EnrollStudentCommand(1, 0);
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.SubjectId);
}
}
public class UnenrollStudentValidatorTests
{
private readonly UnenrollStudentValidator _validator;
public UnenrollStudentValidatorTests()
{
_validator = new UnenrollStudentValidator();
}
[Fact]
public async Task Validate_WithValidCommand_ShouldNotHaveErrors()
{
// Arrange
var command = new UnenrollStudentCommand(1);
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public async Task Validate_WithInvalidEnrollmentId_ShouldHaveError()
{
// Arrange
var command = new UnenrollStudentCommand(0);
// Act
var result = await _validator.TestValidateAsync(command);
// Assert
result.ShouldHaveValidationErrorFor(x => x.EnrollmentId);
}
}

View File

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="*" />
<PackageReference Include="coverlet.collector" Version="*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,126 @@
namespace Domain.Tests.Entities;
using Domain.Entities;
using Domain.ValueObjects;
using FluentAssertions;
using Xunit;
public class StudentTests
{
private static Email ValidEmail => Email.Create("test@example.com");
[Fact]
public void Constructor_WithValidData_ShouldCreateStudent()
{
var student = new Student("John Doe", ValidEmail);
student.Name.Should().Be("John Doe");
student.Email.Value.Should().Be("test@example.com");
student.Enrollments.Should().BeEmpty();
student.TotalCredits.Should().Be(0);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Constructor_WithInvalidName_ShouldThrow(string? name)
{
var act = () => new Student(name!, ValidEmail);
act.Should().Throw<ArgumentException>()
.WithMessage("*Student name is required*");
}
[Fact]
public void Constructor_WithNullEmail_ShouldThrow()
{
var act = () => new Student("John Doe", null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void CanEnroll_WhenNoEnrollments_ShouldReturnTrue()
{
var student = new Student("John Doe", ValidEmail);
student.CanEnroll().Should().BeTrue();
}
[Fact]
public void CanEnroll_WhenMaxEnrollments_ShouldReturnFalse()
{
var student = new Student("John Doe", ValidEmail);
AddEnrollments(student, 3);
student.CanEnroll().Should().BeFalse();
}
[Fact]
public void HasProfessor_WhenStudentHasSubjectWithProfessor_ShouldReturnTrue()
{
var student = new Student("John Doe", ValidEmail);
var subject = new Subject("Math", professorId: 1);
var enrollment = new Enrollment(student.Id, subject.Id);
SetSubjectOnEnrollment(enrollment, subject);
student.AddEnrollment(enrollment);
student.HasProfessor(1).Should().BeTrue();
}
[Fact]
public void HasProfessor_WhenNoProfessor_ShouldReturnFalse()
{
var student = new Student("John Doe", ValidEmail);
student.HasProfessor(1).Should().BeFalse();
}
[Fact]
public void UpdateName_WithValidName_ShouldUpdate()
{
var student = new Student("John Doe", ValidEmail);
student.UpdateName("Jane Doe");
student.Name.Should().Be("Jane Doe");
}
[Fact]
public void UpdateEmail_WithValidEmail_ShouldUpdate()
{
var student = new Student("John Doe", ValidEmail);
var newEmail = Email.Create("new@example.com");
student.UpdateEmail(newEmail);
student.Email.Value.Should().Be("new@example.com");
}
[Fact]
public void TotalCredits_ShouldSumEnrollmentCredits()
{
var student = new Student("John Doe", ValidEmail);
AddEnrollments(student, 2);
student.TotalCredits.Should().Be(6);
}
private static void AddEnrollments(Student student, int count)
{
for (int i = 0; i < count; i++)
{
var subject = new Subject($"Subject{i}", professorId: i + 1);
var enrollment = new Enrollment(student.Id, subject.Id);
SetSubjectOnEnrollment(enrollment, subject);
student.AddEnrollment(enrollment);
}
}
private static void SetSubjectOnEnrollment(Enrollment enrollment, Subject subject)
{
var subjectProperty = typeof(Enrollment).GetProperty("Subject")!;
subjectProperty.SetValue(enrollment, subject);
}
}

View File

@ -0,0 +1,125 @@
namespace Domain.Tests.Services;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Services;
using Domain.ValueObjects;
using FluentAssertions;
using Xunit;
public class EnrollmentDomainServiceTests
{
private readonly EnrollmentDomainService _sut = new();
private static Email ValidEmail => Email.Create("test@example.com");
[Fact]
public void ValidateEnrollment_WhenValid_ShouldNotThrow()
{
var student = new Student("John Doe", ValidEmail);
var subject = new Subject("Math", professorId: 1);
var act = () => _sut.ValidateEnrollment(student, subject);
act.Should().NotThrow();
}
[Fact]
public void ValidateEnrollment_WhenMaxEnrollmentsReached_ShouldThrow()
{
var student = new Student("John Doe", ValidEmail);
AddEnrollmentsWithDifferentProfessors(student, 3);
var subject = new Subject("Physics", professorId: 99);
var act = () => _sut.ValidateEnrollment(student, subject);
act.Should().Throw<MaxEnrollmentsExceededException>()
.Which.Code.Should().Be("MAX_ENROLLMENTS");
}
[Fact]
public void ValidateEnrollment_WhenSameProfessor_ShouldThrow()
{
var student = new Student("John Doe", ValidEmail);
var existingSubject = new Subject("Math", professorId: 1);
AddEnrollmentWithSubject(student, existingSubject);
var newSubject = new Subject("Algebra", professorId: 1);
var act = () => _sut.ValidateEnrollment(student, newSubject);
act.Should().Throw<SameProfessorConstraintException>()
.Which.Code.Should().Be("SAME_PROFESSOR");
}
[Fact]
public void ValidateEnrollment_WhenDuplicateSubject_ShouldThrow()
{
var student = new Student("John Doe", ValidEmail);
var subject = new Subject("Math", professorId: 1);
SetSubjectId(subject, 10);
AddEnrollmentWithSubject(student, subject);
// Same subject ID but different professor to bypass professor check
var duplicateSubject = new Subject("Math", professorId: 2);
SetSubjectId(duplicateSubject, 10);
var act = () => _sut.ValidateEnrollment(student, duplicateSubject);
act.Should().Throw<DuplicateEnrollmentException>()
.Which.Code.Should().Be("DUPLICATE_ENROLLMENT");
}
[Fact]
public void CreateEnrollment_WhenValid_ShouldCreateAndAddToStudent()
{
var student = new Student("John Doe", ValidEmail);
var subject = new Subject("Math", professorId: 1);
var enrollment = _sut.CreateEnrollment(student, subject);
enrollment.Should().NotBeNull();
student.Enrollments.Should().Contain(enrollment);
enrollment.StudentId.Should().Be(student.Id);
enrollment.SubjectId.Should().Be(subject.Id);
}
[Fact]
public void CreateEnrollment_WhenInvalid_ShouldNotAddToStudent()
{
var student = new Student("John Doe", ValidEmail);
AddEnrollmentsWithDifferentProfessors(student, 3);
var subject = new Subject("Physics", professorId: 99);
var act = () => _sut.CreateEnrollment(student, subject);
act.Should().Throw<MaxEnrollmentsExceededException>();
student.Enrollments.Should().HaveCount(3);
}
private static void AddEnrollmentsWithDifferentProfessors(Student student, int count)
{
for (int i = 0; i < count; i++)
{
var subject = new Subject($"Subject{i}", professorId: i + 1);
SetSubjectId(subject, i + 1);
AddEnrollmentWithSubject(student, subject);
}
}
private static void AddEnrollmentWithSubject(Student student, Subject subject)
{
var enrollment = new Enrollment(student.Id, subject.Id);
SetSubjectOnEnrollment(enrollment, subject);
student.AddEnrollment(enrollment);
}
private static void SetSubjectOnEnrollment(Enrollment enrollment, Subject subject)
{
typeof(Enrollment).GetProperty("Subject")!.SetValue(enrollment, subject);
}
private static void SetSubjectId(Subject subject, int id)
{
typeof(Subject).GetProperty("Id")!.SetValue(subject, id);
}
}

View File

@ -0,0 +1,63 @@
namespace Domain.Tests.ValueObjects;
using Domain.ValueObjects;
using FluentAssertions;
using Xunit;
public class EmailTests
{
[Theory]
[InlineData("test@example.com")]
[InlineData("user.name@domain.org")]
[InlineData("UPPER@CASE.COM")]
public void Create_WithValidEmail_ShouldSucceed(string email)
{
var result = Email.Create(email);
result.Value.Should().Be(email.ToLowerInvariant());
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Create_WithEmptyEmail_ShouldThrow(string? email)
{
var act = () => Email.Create(email!);
act.Should().Throw<ArgumentException>()
.WithMessage("*Email is required*");
}
[Theory]
[InlineData("notanemail")]
[InlineData("missing@domain")]
[InlineData("@nodomain.com")]
[InlineData("spaces in@email.com")]
public void Create_WithInvalidFormat_ShouldThrow(string email)
{
var act = () => Email.Create(email);
act.Should().Throw<ArgumentException>()
.WithMessage("*Invalid email format*");
}
[Fact]
public void Equals_WithSameValue_ShouldBeEqual()
{
var email1 = Email.Create("test@example.com");
var email2 = Email.Create("TEST@EXAMPLE.COM");
email1.Should().Be(email2);
}
[Fact]
public void ImplicitConversion_ToString_ShouldWork()
{
var email = Email.Create("test@example.com");
string value = email;
value.Should().Be("test@example.com");
}
}

View File

@ -0,0 +1,214 @@
namespace Integration.Tests;
using Adapters.Driven.Persistence.Context;
using Adapters.Driven.Persistence.Repositories;
using Application.Enrollments.Commands;
using Application.Students.Commands;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Services;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
public class EnrollmentFlowTests : IDisposable
{
private readonly AppDbContext _context;
private readonly StudentRepository _studentRepository;
private readonly SubjectRepository _subjectRepository;
private readonly EnrollmentRepository _enrollmentRepository;
private readonly UnitOfWork _unitOfWork;
public EnrollmentFlowTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options);
_studentRepository = new StudentRepository(_context);
_subjectRepository = new SubjectRepository(_context);
_enrollmentRepository = new EnrollmentRepository(_context);
_unitOfWork = new UnitOfWork(_context);
SeedData();
}
private void SeedData()
{
var prof1 = new Professor("Dr. Smith");
var prof2 = new Professor("Dr. Jones");
_context.Professors.AddRange(prof1, prof2);
_context.SaveChanges();
var subjects = new[]
{
new Subject("Mathematics", prof1.Id),
new Subject("Physics", prof1.Id),
new Subject("Chemistry", prof2.Id),
new Subject("Biology", prof2.Id)
};
_context.Subjects.AddRange(subjects);
_context.SaveChanges();
}
[Fact]
public async Task CreateStudent_ShouldPersistToDatabase()
{
// Arrange
var handler = new CreateStudentHandler(_studentRepository, _unitOfWork);
var command = new CreateStudentCommand("John Doe", "john@example.com");
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("John Doe");
var savedStudent = await _context.Students.FirstOrDefaultAsync(s => s.Email.Value == "john@example.com");
savedStudent.Should().NotBeNull();
}
[Fact]
public async Task EnrollStudent_ShouldCreateEnrollment()
{
// Arrange
var student = new Student("Jane Doe", Domain.ValueObjects.Email.Create("jane@example.com"));
_context.Students.Add(student);
await _context.SaveChangesAsync();
var subject = await _context.Subjects.FirstAsync();
var enrollmentService = new EnrollmentDomainService();
var handler = new EnrollStudentHandler(
_studentRepository, _subjectRepository, _enrollmentRepository,
enrollmentService, _unitOfWork);
var command = new EnrollStudentCommand(student.Id, subject.Id);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
var enrollment = await _context.Enrollments
.FirstOrDefaultAsync(e => e.StudentId == student.Id && e.SubjectId == subject.Id);
enrollment.Should().NotBeNull();
}
[Fact]
public async Task EnrollStudent_WhenMaxEnrollments_ShouldThrow()
{
// Arrange
var student = new Student("Max Student", Domain.ValueObjects.Email.Create("max@example.com"));
_context.Students.Add(student);
await _context.SaveChangesAsync();
var subjects = await _context.Subjects.Take(4).ToListAsync();
var enrollmentService = new EnrollmentDomainService();
// Enroll in 3 subjects (max allowed) with different professors
var subjectsWithDiffProfs = subjects.GroupBy(s => s.ProfessorId).Select(g => g.First()).Take(3).ToList();
if (subjectsWithDiffProfs.Count < 3)
{
// If we don't have 3 different professors, just use first 3 subjects
subjectsWithDiffProfs = subjects.Take(3).ToList();
}
foreach (var subject in subjectsWithDiffProfs.Take(2))
{
_context.Enrollments.Add(new Enrollment(student.Id, subject.Id));
}
await _context.SaveChangesAsync();
// Need to reload student with enrollments
_context.Entry(student).State = EntityState.Detached;
var handler = new EnrollStudentHandler(
_studentRepository, _subjectRepository, _enrollmentRepository,
enrollmentService, _unitOfWork);
// Add third enrollment
var thirdSubject = subjectsWithDiffProfs.Skip(2).First();
var thirdCommand = new EnrollStudentCommand(student.Id, thirdSubject.Id);
await handler.Handle(thirdCommand, CancellationToken.None);
// Try fourth enrollment - should fail
var fourthSubject = subjects.First(s => !subjectsWithDiffProfs.Contains(s));
var fourthCommand = new EnrollStudentCommand(student.Id, fourthSubject.Id);
// Act
var act = () => handler.Handle(fourthCommand, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<MaxEnrollmentsExceededException>();
}
[Fact]
public async Task EnrollStudent_WhenSameProfessor_ShouldThrow()
{
// Arrange
var student = new Student("Prof Test", Domain.ValueObjects.Email.Create("prof@example.com"));
_context.Students.Add(student);
await _context.SaveChangesAsync();
// Get all subjects and find two with same professor in memory
var allSubjects = await _context.Subjects.ToListAsync();
var subjectsSameProf = allSubjects
.GroupBy(s => s.ProfessorId)
.Where(g => g.Count() >= 2)
.SelectMany(g => g.Take(2))
.ToList();
if (subjectsSameProf.Count < 2)
{
// Skip test if we don't have subjects with same professor
return;
}
var enrollmentService = new EnrollmentDomainService();
var handler = new EnrollStudentHandler(
_studentRepository, _subjectRepository, _enrollmentRepository,
enrollmentService, _unitOfWork);
// Enroll first subject
var firstCommand = new EnrollStudentCommand(student.Id, subjectsSameProf[0].Id);
await handler.Handle(firstCommand, CancellationToken.None);
// Try second subject with same professor
var secondCommand = new EnrollStudentCommand(student.Id, subjectsSameProf[1].Id);
// Act
var act = () => handler.Handle(secondCommand, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<SameProfessorConstraintException>();
}
[Fact]
public async Task DeleteStudent_ShouldRemoveFromDatabase()
{
// Arrange
var student = new Student("Delete Me", Domain.ValueObjects.Email.Create("delete@example.com"));
_context.Students.Add(student);
await _context.SaveChangesAsync();
var studentId = student.Id;
var handler = new DeleteStudentHandler(_studentRepository, _unitOfWork);
var command = new DeleteStudentCommand(studentId);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeTrue();
var deletedStudent = await _context.Students.FindAsync(studentId);
deletedStudent.Should().BeNull();
}
public void Dispose()
{
_context.Dispose();
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="FluentAssertions" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\backend\Application\Application.csproj" />
<ProjectReference Include="..\..\src\backend\Adapters\Driven\Persistence\Adapters.Driven.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>