diff --git a/tests/Application.Tests/Application.Tests.csproj b/tests/Application.Tests/Application.Tests.csproj
new file mode 100644
index 0000000..6ee6157
--- /dev/null
+++ b/tests/Application.Tests/Application.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
+
+
+
+
diff --git a/tests/Application.Tests/Enrollments/EnrollStudentCommandTests.cs b/tests/Application.Tests/Enrollments/EnrollStudentCommandTests.cs
new file mode 100644
index 0000000..0f1e721
--- /dev/null
+++ b/tests/Application.Tests/Enrollments/EnrollStudentCommandTests.cs
@@ -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();
+ private readonly ISubjectRepository _subjectRepo = Substitute.For();
+ private readonly IEnrollmentRepository _enrollmentRepo = Substitute.For();
+ private readonly IUnitOfWork _unitOfWork = Substitute.For();
+ 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())
+ .Returns(student);
+ _subjectRepo.GetByIdWithProfessorAsync(1, Arg.Any())
+ .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());
+ await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentNotFound_ShouldThrow()
+ {
+ _studentRepo.GetByIdWithEnrollmentsAsync(999, Arg.Any())
+ .Returns((Student?)null);
+
+ var command = new EnrollStudentCommand(999, 1);
+ var act = () => _sut.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Handle_WhenSubjectNotFound_ShouldThrow()
+ {
+ var student = CreateStudent();
+ _studentRepo.GetByIdWithEnrollmentsAsync(1, Arg.Any())
+ .Returns(student);
+ _subjectRepo.GetByIdWithProfessorAsync(999, Arg.Any())
+ .Returns((Subject?)null);
+
+ var command = new EnrollStudentCommand(1, 999);
+ var act = () => _sut.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Handle_WhenMaxEnrollmentsReached_ShouldThrow()
+ {
+ var student = CreateStudentWithEnrollments(3);
+ var subject = CreateSubjectWithProfessor(10, "Physics", 99, "New Prof");
+
+ _studentRepo.GetByIdWithEnrollmentsAsync(1, Arg.Any())
+ .Returns(student);
+ _subjectRepo.GetByIdWithProfessorAsync(10, Arg.Any())
+ .Returns(subject);
+
+ var command = new EnrollStudentCommand(1, 10);
+ var act = () => _sut.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ _enrollmentRepo.DidNotReceive().Add(Arg.Any());
+ }
+
+ [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())
+ .Returns(student);
+ _subjectRepo.GetByIdWithProfessorAsync(2, Arg.Any())
+ .Returns(newSubject);
+
+ var command = new EnrollStudentCommand(1, 2);
+ var act = () => _sut.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ }
+
+ 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);
+ }
+}
diff --git a/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs b/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs
new file mode 100644
index 0000000..3d22e9a
--- /dev/null
+++ b/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs
@@ -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();
+ _enrollmentRepository = Substitute.For();
+ _handler = new GetClassmatesHandler(_studentRepository, _enrollmentRepository);
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentNotFound_ShouldThrow()
+ {
+ // Arrange
+ _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any())
+ .Returns((Student?)null);
+
+ var query = new GetClassmatesQuery(1);
+
+ // Act
+ var act = () => _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentHasNoEnrollments_ShouldReturnEmptyList()
+ {
+ // Arrange
+ var student = new Student("John Doe", Email.Create("john@test.com"));
+ _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any())
+ .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(), Arg.Any>(), Arg.Any());
+ }
+
+ [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())
+ .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>
+ {
+ [1] = [classmate1],
+ [2] = [classmate2]
+ };
+
+ _enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any>(), Arg.Any())
+ .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>(), Arg.Any());
+ }
+
+ [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())
+ .Returns(student);
+
+ var emptyBatchResult = new Dictionary>();
+ _enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any>(), Arg.Any())
+ .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 entity, int id) where T : class
+ {
+ var prop = typeof(T).GetProperty("Id");
+ prop?.SetValue(entity, id);
+ }
+
+ private static void SetNavigationProperty(T entity, string propertyName, object value) where T : class
+ {
+ var prop = typeof(T).GetProperty(propertyName);
+ prop?.SetValue(entity, value);
+ }
+}
diff --git a/tests/Application.Tests/Enrollments/UnenrollStudentCommandTests.cs b/tests/Application.Tests/Enrollments/UnenrollStudentCommandTests.cs
new file mode 100644
index 0000000..d66fd8d
--- /dev/null
+++ b/tests/Application.Tests/Enrollments/UnenrollStudentCommandTests.cs
@@ -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();
+ _studentRepository = Substitute.For();
+ _unitOfWork = Substitute.For();
+ _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())
+ .Returns(enrollment);
+ _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any())
+ .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());
+ }
+
+ [Fact]
+ public async Task Handle_WhenEnrollmentNotFound_ShouldThrow()
+ {
+ // Arrange
+ _enrollmentRepository.GetByIdAsync(999, Arg.Any())
+ .Returns((Enrollment?)null);
+
+ var command = new UnenrollStudentCommand(999);
+
+ // Act
+ var act = () => _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentNotFound_ShouldStillDeleteEnrollment()
+ {
+ // Arrange
+ var enrollment = new Enrollment(999, 1);
+ SetEntityId(enrollment, 1);
+
+ _enrollmentRepository.GetByIdAsync(1, Arg.Any())
+ .Returns(enrollment);
+ _studentRepository.GetByIdWithEnrollmentsAsync(999, Arg.Any())
+ .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 entity, int id) where T : class
+ {
+ typeof(T).GetProperty("Id")?.SetValue(entity, id);
+ }
+}
diff --git a/tests/Application.Tests/Professors/ProfessorQueriesTests.cs b/tests/Application.Tests/Professors/ProfessorQueriesTests.cs
new file mode 100644
index 0000000..a906bcd
--- /dev/null
+++ b/tests/Application.Tests/Professors/ProfessorQueriesTests.cs
@@ -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();
+ _handler = new GetProfessorsHandler(_professorRepository);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnAllProfessors()
+ {
+ // Arrange
+ var professors = new List
+ {
+ 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>>(),
+ Arg.Any())
+ .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>>(),
+ Arg.Any())
+ .Returns(new List());
+
+ 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
+ {
+ new(1, "Math", 3),
+ new(2, "Physics", 3)
+ };
+ var professors = new List
+ {
+ new(1, "Prof A", subjects)
+ };
+
+ _professorRepository.GetAllProjectedAsync(
+ Arg.Any>>(),
+ Arg.Any())
+ .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");
+ }
+}
diff --git a/tests/Application.Tests/Students/StudentCommandsTests.cs b/tests/Application.Tests/Students/StudentCommandsTests.cs
new file mode 100644
index 0000000..284111b
--- /dev/null
+++ b/tests/Application.Tests/Students/StudentCommandsTests.cs
@@ -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();
+ _unitOfWork = Substitute.For();
+ _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());
+ await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any());
+ }
+
+ [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();
+ }
+
+ [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();
+ }
+}
+
+public class UpdateStudentCommandTests
+{
+ private readonly IStudentRepository _studentRepository;
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly UpdateStudentHandler _handler;
+
+ public UpdateStudentCommandTests()
+ {
+ _studentRepository = Substitute.For();
+ _unitOfWork = Substitute.For();
+ _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())
+ .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());
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentNotFound_ShouldThrow()
+ {
+ // Arrange
+ _studentRepository.GetByIdWithEnrollmentsAsync(999, Arg.Any())
+ .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();
+ }
+
+ [Fact]
+ public async Task Handle_WithInvalidEmail_ShouldThrow()
+ {
+ // Arrange
+ var student = new Student("Name", Email.Create("old@example.com"));
+ _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any())
+ .Returns(student);
+
+ var command = new UpdateStudentCommand(1, "Name", "invalid");
+
+ // Act
+ var act = () => _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ private static void SetEntityId(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();
+ _unitOfWork = Substitute.For();
+ _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())
+ .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());
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentNotFound_ShouldThrow()
+ {
+ // Arrange
+ _studentRepository.GetByIdAsync(999, Arg.Any())
+ .Returns((Student?)null);
+
+ var command = new DeleteStudentCommand(999);
+
+ // Act
+ var act = () => _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+}
diff --git a/tests/Application.Tests/Students/StudentQueriesTests.cs b/tests/Application.Tests/Students/StudentQueriesTests.cs
new file mode 100644
index 0000000..40f6e49
--- /dev/null
+++ b/tests/Application.Tests/Students/StudentQueriesTests.cs
@@ -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();
+ _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>>(),
+ Arg.Any())
+ .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(
+ 999,
+ Arg.Any>>(),
+ Arg.Any())
+ .Returns((StudentDto?)null);
+
+ var query = new GetStudentByIdQuery(999);
+
+ // Act
+ var act = () => _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentHasEnrollments_ShouldReturnWithEnrollments()
+ {
+ // Arrange
+ var enrollments = new List
+ {
+ 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>>(),
+ Arg.Any())
+ .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();
+ _handler = new GetStudentsHandler(_studentRepository);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnAllStudents()
+ {
+ // Arrange
+ var students = new List
+ {
+ new(1, "John Doe", "john@example.com", 0, []),
+ new(2, "Jane Doe", "jane@example.com", 3, [])
+ };
+
+ _studentRepository.GetAllProjectedAsync(
+ Arg.Any>>(),
+ Arg.Any())
+ .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>>(),
+ Arg.Any())
+ .Returns(new List());
+
+ 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();
+ _handler = new GetStudentsPagedHandler(_studentRepository);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnPagedResult()
+ {
+ // Arrange
+ var items = new List
+ {
+ new(1, "John Doe", "john@example.com", 0),
+ new(2, "Jane Doe", "jane@example.com", 3)
+ };
+
+ _studentRepository.GetPagedProjectedAsync(
+ Arg.Any>>(),
+ null,
+ 10,
+ Arg.Any())
+ .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>>(),
+ 5,
+ 10,
+ Arg.Any())
+ .Returns((new List(), (int?)null, 5));
+
+ var query = new GetStudentsPagedQuery(AfterCursor: 5);
+
+ // Act
+ await _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ await _studentRepository.Received(1).GetPagedProjectedAsync(
+ Arg.Any>>(),
+ 5,
+ 10,
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_WithLargePageSize_ShouldLimitTo50()
+ {
+ // Arrange
+ _studentRepository.GetPagedProjectedAsync(
+ Arg.Any>>(),
+ Arg.Any(),
+ 50,
+ Arg.Any())
+ .Returns((new List(), (int?)null, 0));
+
+ var query = new GetStudentsPagedQuery(PageSize: 100);
+
+ // Act
+ await _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ await _studentRepository.Received(1).GetPagedProjectedAsync(
+ Arg.Any>>(),
+ null,
+ 50,
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Handle_WhenNoNextPage_ShouldReturnHasNextPageFalse()
+ {
+ // Arrange
+ _studentRepository.GetPagedProjectedAsync(
+ Arg.Any>>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns((new List(), (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();
+ }
+}
diff --git a/tests/Application.Tests/Subjects/SubjectQueriesTests.cs b/tests/Application.Tests/Subjects/SubjectQueriesTests.cs
new file mode 100644
index 0000000..1eb8445
--- /dev/null
+++ b/tests/Application.Tests/Subjects/SubjectQueriesTests.cs
@@ -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();
+ _handler = new GetSubjectsHandler(_subjectRepository);
+ }
+
+ [Fact]
+ public async Task Handle_ShouldReturnAllSubjects()
+ {
+ // Arrange
+ var subjects = new List
+ {
+ 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>>(),
+ Arg.Any())
+ .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>>(),
+ Arg.Any())
+ .Returns(new List());
+
+ 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
+ {
+ new(1, "Math", 3, 1, "Prof A")
+ };
+
+ _subjectRepository.GetAllProjectedAsync(
+ Arg.Any>>(),
+ Arg.Any())
+ .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();
+ _subjectRepository = Substitute.For();
+ _handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository);
+ }
+
+ [Fact]
+ public async Task Handle_WhenStudentNotFound_ShouldThrow()
+ {
+ // Arrange
+ _studentRepository.GetByIdWithEnrollmentsAsync(999, Arg.Any())
+ .Returns((Domain.Entities.Student?)null);
+
+ var query = new GetAvailableSubjectsQuery(999);
+
+ // Act
+ var act = () => _handler.Handle(query, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [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
+ {
+ CreateSubject(1, "Math", 1, professor),
+ CreateSubject(2, "Physics", 1, professor)
+ };
+
+ _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any())
+ .Returns(student);
+ _subjectRepository.GetAllWithProfessorsAsync(Arg.Any())
+ .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())
+ .Returns(student);
+ _subjectRepository.GetAllWithProfessorsAsync(Arg.Any())
+ .Returns(new List { 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())
+ .Returns(student);
+ _subjectRepository.GetAllWithProfessorsAsync(Arg.Any())
+ .Returns(new List { 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())
+ .Returns(student);
+ _subjectRepository.GetAllWithProfessorsAsync(Arg.Any())
+ .Returns(new List { 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 entity, int id) where T : class
+ {
+ typeof(T).GetProperty("Id")?.SetValue(entity, id);
+ }
+
+ private static void SetNavigationProperty(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;
+ }
+}
diff --git a/tests/Application.Tests/Validators/ValidatorTests.cs b/tests/Application.Tests/Validators/ValidatorTests.cs
new file mode 100644
index 0000000..97a827e
--- /dev/null
+++ b/tests/Application.Tests/Validators/ValidatorTests.cs
@@ -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();
+ _studentRepository.EmailExistsAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .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())
+ .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("")]
+ [InlineData("John
")]
+ [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();
+ _studentRepository.EmailExistsAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .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);
+ }
+}
diff --git a/tests/Domain.Tests/Domain.Tests.csproj b/tests/Domain.Tests/Domain.Tests.csproj
new file mode 100644
index 0000000..17f587b
--- /dev/null
+++ b/tests/Domain.Tests/Domain.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
+
+
+
+
diff --git a/tests/Domain.Tests/Entities/StudentTests.cs b/tests/Domain.Tests/Entities/StudentTests.cs
new file mode 100644
index 0000000..52834d2
--- /dev/null
+++ b/tests/Domain.Tests/Entities/StudentTests.cs
@@ -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()
+ .WithMessage("*Student name is required*");
+ }
+
+ [Fact]
+ public void Constructor_WithNullEmail_ShouldThrow()
+ {
+ var act = () => new Student("John Doe", null!);
+
+ act.Should().Throw();
+ }
+
+ [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);
+ }
+}
diff --git a/tests/Domain.Tests/Services/EnrollmentDomainServiceTests.cs b/tests/Domain.Tests/Services/EnrollmentDomainServiceTests.cs
new file mode 100644
index 0000000..bfc5233
--- /dev/null
+++ b/tests/Domain.Tests/Services/EnrollmentDomainServiceTests.cs
@@ -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()
+ .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()
+ .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()
+ .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();
+ 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);
+ }
+}
diff --git a/tests/Domain.Tests/ValueObjects/EmailTests.cs b/tests/Domain.Tests/ValueObjects/EmailTests.cs
new file mode 100644
index 0000000..f5ca538
--- /dev/null
+++ b/tests/Domain.Tests/ValueObjects/EmailTests.cs
@@ -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()
+ .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()
+ .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");
+ }
+}
diff --git a/tests/Integration.Tests/EnrollmentFlowTests.cs b/tests/Integration.Tests/EnrollmentFlowTests.cs
new file mode 100644
index 0000000..297b49a
--- /dev/null
+++ b/tests/Integration.Tests/EnrollmentFlowTests.cs
@@ -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()
+ .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();
+ }
+
+ [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();
+ }
+
+ [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();
+ }
+}
diff --git a/tests/Integration.Tests/Integration.Tests.csproj b/tests/Integration.Tests/Integration.Tests.csproj
new file mode 100644
index 0000000..de4f379
--- /dev/null
+++ b/tests/Integration.Tests/Integration.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+