From 874b67d07ffdc611778af3beaca407b7fad26426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Eduardo=20Garc=C3=ADa=20M=C3=A1rquez?= Date: Wed, 7 Jan 2026 23:00:27 -0500 Subject: [PATCH] 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 --- .../Application.Tests.csproj | 30 ++ .../Enrollments/EnrollStudentCommandTests.cs | 155 +++++++++ .../Enrollments/GetClassmatesQueryTests.cs | 157 +++++++++ .../UnenrollStudentCommandTests.cs | 96 ++++++ .../Professors/ProfessorQueriesTests.cs | 94 +++++ .../Students/StudentCommandsTests.cs | 196 +++++++++++ .../Students/StudentQueriesTests.cs | 258 ++++++++++++++ .../Subjects/SubjectQueriesTests.cs | 278 +++++++++++++++ .../Validators/ValidatorTests.cs | 322 ++++++++++++++++++ tests/Domain.Tests/Domain.Tests.csproj | 29 ++ tests/Domain.Tests/Entities/StudentTests.cs | 126 +++++++ .../Services/EnrollmentDomainServiceTests.cs | 125 +++++++ tests/Domain.Tests/ValueObjects/EmailTests.cs | 63 ++++ .../Integration.Tests/EnrollmentFlowTests.cs | 214 ++++++++++++ .../Integration.Tests.csproj | 28 ++ 15 files changed, 2171 insertions(+) create mode 100644 tests/Application.Tests/Application.Tests.csproj create mode 100644 tests/Application.Tests/Enrollments/EnrollStudentCommandTests.cs create mode 100644 tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs create mode 100644 tests/Application.Tests/Enrollments/UnenrollStudentCommandTests.cs create mode 100644 tests/Application.Tests/Professors/ProfessorQueriesTests.cs create mode 100644 tests/Application.Tests/Students/StudentCommandsTests.cs create mode 100644 tests/Application.Tests/Students/StudentQueriesTests.cs create mode 100644 tests/Application.Tests/Subjects/SubjectQueriesTests.cs create mode 100644 tests/Application.Tests/Validators/ValidatorTests.cs create mode 100644 tests/Domain.Tests/Domain.Tests.csproj create mode 100644 tests/Domain.Tests/Entities/StudentTests.cs create mode 100644 tests/Domain.Tests/Services/EnrollmentDomainServiceTests.cs create mode 100644 tests/Domain.Tests/ValueObjects/EmailTests.cs create mode 100644 tests/Integration.Tests/EnrollmentFlowTests.cs create mode 100644 tests/Integration.Tests/Integration.Tests.csproj 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 + + + + + + + + + + + + + + + + + + + + +