diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs index f0d5c36..6901d62 100644 --- a/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs +++ b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs @@ -3,8 +3,12 @@ namespace Adapters.Driven.Persistence.Repositories; using Adapters.Driven.Persistence.Context; using Domain.Entities; using Domain.Ports.Repositories; +using Domain.ReadModels; using Microsoft.EntityFrameworkCore; +/// +/// EF Core implementation of . +/// public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository { public async Task GetByIdAsync(int id, CancellationToken ct = default) => @@ -29,45 +33,32 @@ public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository .AsNoTracking() .ToListAsync(ct); - public async Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) => + public async Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) => await context.Enrollments .Where(e => e.SubjectId == subjectId && e.StudentId != studentId) - .Select(e => e.Student) - .AsNoTracking() + .Select(e => new ClassmateInfo(e.Student.Id, e.Student.Name)) .ToListAsync(ct); - public async Task>> GetClassmatesBatchAsync( + public async Task>> GetClassmatesBatchAsync( int studentId, IEnumerable subjectIds, CancellationToken ct = default) { var subjectIdList = subjectIds.ToList(); if (subjectIdList.Count == 0) - return new Dictionary>(); + return new Dictionary>(); - // Single query to get all classmates for all subjects + // Single query to get all classmates for all subjects - projects directly to ClassmateInfo + // Note: AsNoTracking is not needed for projections (Select) as they're not tracked by default var enrollments = await context.Enrollments .Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId) - .Select(e => new { e.SubjectId, e.Student.Id, e.Student.Name }) - .AsNoTracking() + .Select(e => new { e.SubjectId, Classmate = new ClassmateInfo(e.Student.Id, e.Student.Name) }) .ToListAsync(ct); - // Group by SubjectId and project to Student (minimal data needed) + // Group by SubjectId return enrollments .GroupBy(e => e.SubjectId) .ToDictionary( g => g.Key, - g => (IReadOnlyList)g - .Select(e => CreateMinimalStudent(e.Id, e.Name)) - .ToList()); - } - - private static Student CreateMinimalStudent(int id, string name) - { - // Use reflection to set Id since it's private set - var student = (Student)System.Runtime.CompilerServices.RuntimeHelpers - .GetUninitializedObject(typeof(Student)); - typeof(Student).GetProperty("Id")!.SetValue(student, id); - typeof(Student).GetProperty("Name")!.SetValue(student, name); - return student; + g => (IReadOnlyList)g.Select(e => e.Classmate).ToList()); } public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment); diff --git a/src/backend/Application/Common/ValidationPatterns.cs b/src/backend/Application/Common/ValidationPatterns.cs new file mode 100644 index 0000000..7ca41b8 --- /dev/null +++ b/src/backend/Application/Common/ValidationPatterns.cs @@ -0,0 +1,69 @@ +namespace Application.Common; + +using System.Text.RegularExpressions; + +/// +/// Shared validation patterns and constants for input validation. +/// Used across validators to ensure consistency and DRY principle. +/// +public static partial class ValidationPatterns +{ + #region Name Validation + + /// + /// Minimum length for student/person names. + /// + public const int MinNameLength = 3; + + /// + /// Maximum length for student/person names. + /// + public const int MaxNameLength = 100; + + /// + /// Pattern for valid name characters. + /// Allows: Unicode letters, spaces, hyphens, apostrophes, and periods. + /// Examples: "Juan Pérez", "María O'Brien", "Jean-Pierre" + /// + [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)] + public static partial Regex SafeNamePattern(); + + #endregion + + #region Email Validation + + /// + /// Maximum length for email addresses (RFC 5321). + /// + public const int MaxEmailLength = 254; + + #endregion + + #region Security Patterns + + /// + /// Pattern to detect potentially dangerous content (XSS, script injection). + /// Matches: HTML tags, javascript: protocol, event handlers (onclick, onload, etc.) + /// + [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + public static partial Regex DangerousContentPattern(); + + /// + /// Checks if a string contains potentially dangerous content. + /// + /// The string to check. + /// True if the content is safe (no dangerous patterns found). + public static bool IsSafeContent(string? value) => + string.IsNullOrEmpty(value) || !DangerousContentPattern().IsMatch(value); + + #endregion + + #region ID Validation + + /// + /// Error message for invalid entity ID. + /// + public const string InvalidIdMessage = "ID must be greater than 0"; + + #endregion +} diff --git a/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs index 3fac178..9aa1312 100644 --- a/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs +++ b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs @@ -1,24 +1,33 @@ namespace Application.Enrollments.Commands; +using Application.Common; using FluentValidation; +/// +/// Validator for . +/// Validates that student and subject IDs are valid. +/// public class EnrollStudentValidator : AbstractValidator { public EnrollStudentValidator() { RuleFor(x => x.StudentId) - .GreaterThan(0).WithMessage("Invalid student ID"); + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage); RuleFor(x => x.SubjectId) - .GreaterThan(0).WithMessage("Invalid subject ID"); + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage); } } +/// +/// Validator for . +/// Validates that enrollment ID is valid. +/// public class UnenrollStudentValidator : AbstractValidator { public UnenrollStudentValidator() { RuleFor(x => x.EnrollmentId) - .GreaterThan(0).WithMessage("Invalid enrollment ID"); + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage); } } diff --git a/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs index 1b7ae3f..910f2d4 100644 --- a/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs +++ b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs @@ -5,8 +5,16 @@ using Domain.Exceptions; using Domain.Ports.Repositories; using MediatR; +/// +/// Query to retrieve classmates for all subjects a student is enrolled in. +/// +/// The student's unique identifier. public record GetClassmatesQuery(int StudentId) : IRequest>; +/// +/// Handler for . +/// Uses batch query to avoid N+1 problem. +/// public class GetClassmatesHandler( IStudentRepository studentRepository, IEnrollmentRepository enrollmentRepository) @@ -31,7 +39,7 @@ public class GetClassmatesHandler( enrollment.SubjectId, enrollment.Subject?.Name ?? "", classmatesBySubject.TryGetValue(enrollment.SubjectId, out var classmates) - ? classmates.Select(c => new ClassmateDto(c.Id, c.Name)).ToList() + ? classmates.Select(c => new ClassmateDto(c.StudentId, c.Name)).ToList() : [] )).ToList(); } diff --git a/src/backend/Application/Enrollments/Queries/GetClassmatesValidator.cs b/src/backend/Application/Enrollments/Queries/GetClassmatesValidator.cs new file mode 100644 index 0000000..24dfce6 --- /dev/null +++ b/src/backend/Application/Enrollments/Queries/GetClassmatesValidator.cs @@ -0,0 +1,17 @@ +namespace Application.Enrollments.Queries; + +using Application.Common; +using FluentValidation; + +/// +/// Validator for . +/// Ensures the student ID is valid before querying classmates. +/// +public class GetClassmatesValidator : AbstractValidator +{ + public GetClassmatesValidator() + { + RuleFor(x => x.StudentId) + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage); + } +} diff --git a/src/backend/Application/Students/Commands/CreateStudentValidator.cs b/src/backend/Application/Students/Commands/CreateStudentValidator.cs index e371de4..0bc8f4d 100644 --- a/src/backend/Application/Students/Commands/CreateStudentValidator.cs +++ b/src/backend/Application/Students/Commands/CreateStudentValidator.cs @@ -1,70 +1,72 @@ namespace Application.Students.Commands; -using System.Text.RegularExpressions; +using Application.Common; using Domain.Ports.Repositories; using FluentValidation; -public partial class CreateStudentValidator : AbstractValidator +/// +/// Validator for . +/// Validates name format, email uniqueness, and prevents XSS attacks. +/// +public class CreateStudentValidator : AbstractValidator { - [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)] - private static partial Regex SafeNamePattern(); - - [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex DangerousPattern(); - public CreateStudentValidator(IStudentRepository studentRepository) { RuleFor(x => x.Name) .NotEmpty().WithMessage("Name is required") - .MinimumLength(3).WithMessage("Name must be at least 3 characters") - .MaximumLength(100).WithMessage("Name must not exceed 100 characters") - .Matches(SafeNamePattern()).WithMessage("Name contains invalid characters") - .Must(NotContainDangerousContent).WithMessage("Name contains prohibited content"); + .MinimumLength(ValidationPatterns.MinNameLength) + .WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters") + .MaximumLength(ValidationPatterns.MaxNameLength) + .WithMessage($"Name must not exceed {ValidationPatterns.MaxNameLength} characters") + .Matches(ValidationPatterns.SafeNamePattern()) + .WithMessage("Name contains invalid characters") + .Must(ValidationPatterns.IsSafeContent) + .WithMessage("Name contains prohibited content"); RuleFor(x => x.Email) .NotEmpty().WithMessage("Email is required") - .MaximumLength(254).WithMessage("Email must not exceed 254 characters") + .MaximumLength(ValidationPatterns.MaxEmailLength) + .WithMessage($"Email must not exceed {ValidationPatterns.MaxEmailLength} characters") .EmailAddress().WithMessage("Invalid email format") - .Must(NotContainDangerousContent).WithMessage("Email contains prohibited content") + .Must(ValidationPatterns.IsSafeContent) + .WithMessage("Email contains prohibited content") .MustAsync(async (email, ct) => !await studentRepository.EmailExistsAsync(email, null, ct)) .WithMessage("Email already exists"); } - - private static bool NotContainDangerousContent(string? value) => - string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value); } -public partial class UpdateStudentValidator : AbstractValidator +/// +/// Validator for . +/// Validates name format, email uniqueness (excluding current student), and prevents XSS attacks. +/// +public class UpdateStudentValidator : AbstractValidator { - [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)] - private static partial Regex SafeNamePattern(); - - [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex DangerousPattern(); - public UpdateStudentValidator(IStudentRepository studentRepository) { RuleFor(x => x.Id) - .GreaterThan(0).WithMessage("Invalid student ID"); + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage); RuleFor(x => x.Name) .NotEmpty().WithMessage("Name is required") - .MinimumLength(3).WithMessage("Name must be at least 3 characters") - .MaximumLength(100).WithMessage("Name must not exceed 100 characters") - .Matches(SafeNamePattern()).WithMessage("Name contains invalid characters") - .Must(NotContainDangerousContent).WithMessage("Name contains prohibited content"); + .MinimumLength(ValidationPatterns.MinNameLength) + .WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters") + .MaximumLength(ValidationPatterns.MaxNameLength) + .WithMessage($"Name must not exceed {ValidationPatterns.MaxNameLength} characters") + .Matches(ValidationPatterns.SafeNamePattern()) + .WithMessage("Name contains invalid characters") + .Must(ValidationPatterns.IsSafeContent) + .WithMessage("Name contains prohibited content"); RuleFor(x => x.Email) .NotEmpty().WithMessage("Email is required") - .MaximumLength(254).WithMessage("Email must not exceed 254 characters") + .MaximumLength(ValidationPatterns.MaxEmailLength) + .WithMessage($"Email must not exceed {ValidationPatterns.MaxEmailLength} characters") .EmailAddress().WithMessage("Invalid email format") - .Must(NotContainDangerousContent).WithMessage("Email contains prohibited content") + .Must(ValidationPatterns.IsSafeContent) + .WithMessage("Email contains prohibited content") .MustAsync(async (command, email, ct) => !await studentRepository.EmailExistsAsync(email, command.Id, ct)) .WithMessage("Email already exists"); } - - private static bool NotContainDangerousContent(string? value) => - string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value); } diff --git a/src/backend/Application/Students/Queries/GetStudentByIdValidator.cs b/src/backend/Application/Students/Queries/GetStudentByIdValidator.cs new file mode 100644 index 0000000..90dfccb --- /dev/null +++ b/src/backend/Application/Students/Queries/GetStudentByIdValidator.cs @@ -0,0 +1,17 @@ +namespace Application.Students.Queries; + +using Application.Common; +using FluentValidation; + +/// +/// Validator for . +/// Ensures the student ID is valid before querying. +/// +public class GetStudentByIdValidator : AbstractValidator +{ + public GetStudentByIdValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage); + } +} diff --git a/src/backend/Application/Students/Queries/GetStudentsPagedValidator.cs b/src/backend/Application/Students/Queries/GetStudentsPagedValidator.cs new file mode 100644 index 0000000..4e9eb1f --- /dev/null +++ b/src/backend/Application/Students/Queries/GetStudentsPagedValidator.cs @@ -0,0 +1,22 @@ +namespace Application.Students.Queries; + +using Application.Common; +using FluentValidation; + +/// +/// Validator for . +/// Validates pagination parameters. +/// +public class GetStudentsPagedValidator : AbstractValidator +{ + public GetStudentsPagedValidator() + { + RuleFor(x => x.PageSize) + .GreaterThan(0).WithMessage("Page size must be greater than 0") + .LessThanOrEqualTo(50).WithMessage("Page size must not exceed 50"); + + RuleFor(x => x.AfterCursor) + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage) + .When(x => x.AfterCursor.HasValue); + } +} diff --git a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs index 19fa1dc..ac48639 100644 --- a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs +++ b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs @@ -1,16 +1,24 @@ namespace Application.Subjects.Queries; using Application.Subjects.DTOs; -using Domain.Entities; using Domain.Exceptions; using Domain.Ports.Repositories; +using Domain.Services; using MediatR; +/// +/// Query to get all subjects with availability status for a specific student. +/// public record GetAvailableSubjectsQuery(int StudentId) : IRequest>; +/// +/// Handler for . +/// Uses domain service for availability logic. +/// public class GetAvailableSubjectsHandler( IStudentRepository studentRepository, - ISubjectRepository subjectRepository) + ISubjectRepository subjectRepository, + EnrollmentDomainService enrollmentDomainService) : IRequestHandler> { public async Task> Handle( @@ -22,41 +30,17 @@ public class GetAvailableSubjectsHandler( var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct); - var enrolledSubjectIds = student.Enrollments.Select(e => e.SubjectId).ToHashSet(); - var enrolledProfessorIds = student.Enrollments - .Select(e => e.Subject?.ProfessorId ?? 0) - .Where(id => id > 0) - .ToHashSet(); - - return allSubjects.Select(s => + return allSubjects.Select(subject => { - var (isAvailable, reason) = GetAvailability(s, student, enrolledSubjectIds, enrolledProfessorIds); + var (isAvailable, reason) = enrollmentDomainService.CheckSubjectAvailability(student, subject); return new AvailableSubjectDto( - s.Id, - s.Name, - s.Credits, - s.ProfessorId, - s.Professor?.Name ?? "", + subject.Id, + subject.Name, + subject.Credits, + subject.ProfessorId, + subject.Professor?.Name ?? "", isAvailable, reason); }).ToList(); } - - private static (bool IsAvailable, string? Reason) GetAvailability( - Subject subject, - Student student, - HashSet enrolledSubjectIds, - HashSet enrolledProfessorIds) - { - if (enrolledSubjectIds.Contains(subject.Id)) - return (false, "Already enrolled"); - - if (!student.CanEnroll()) - return (false, "Maximum 3 subjects reached"); - - if (enrolledProfessorIds.Contains(subject.ProfessorId)) - return (false, "Already have a subject with this professor"); - - return (true, null); - } } diff --git a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsValidator.cs b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsValidator.cs new file mode 100644 index 0000000..66302e9 --- /dev/null +++ b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsValidator.cs @@ -0,0 +1,17 @@ +namespace Application.Subjects.Queries; + +using Application.Common; +using FluentValidation; + +/// +/// Validator for . +/// Ensures the student ID is valid before querying available subjects. +/// +public class GetAvailableSubjectsValidator : AbstractValidator +{ + public GetAvailableSubjectsValidator() + { + RuleFor(x => x.StudentId) + .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage); + } +} diff --git a/src/backend/Domain/Exceptions/DomainException.cs b/src/backend/Domain/Exceptions/DomainException.cs index dfcd94e..7c30d5e 100644 --- a/src/backend/Domain/Exceptions/DomainException.cs +++ b/src/backend/Domain/Exceptions/DomainException.cs @@ -1,7 +1,14 @@ namespace Domain.Exceptions; +/// +/// Base class for all domain exceptions. +/// Provides a machine-readable code for client error handling. +/// public abstract class DomainException : Exception { + /// + /// Machine-readable error code for client handling. + /// public string Code { get; } protected DomainException(string code, string message) : base(message) @@ -10,38 +17,75 @@ public abstract class DomainException : Exception } } +/// +/// Thrown when a student attempts to enroll in more than 3 subjects. +/// public class MaxEnrollmentsExceededException : DomainException { public MaxEnrollmentsExceededException(int studentId) : base("MAX_ENROLLMENTS", $"Student {studentId} cannot enroll in more than 3 subjects") { } } +/// +/// Thrown when a student attempts to enroll in a subject with a professor they already have. +/// public class SameProfessorConstraintException : DomainException { public SameProfessorConstraintException(int studentId, int professorId) : base("SAME_PROFESSOR", $"Student {studentId} already has a subject with professor {professorId}") { } } +/// +/// Thrown when a requested student is not found. +/// public class StudentNotFoundException : DomainException { public StudentNotFoundException(int studentId) : base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { } } +/// +/// Thrown when a requested subject is not found. +/// public class SubjectNotFoundException : DomainException { public SubjectNotFoundException(int subjectId) : base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { } } +/// +/// Thrown when a requested enrollment is not found. +/// public class EnrollmentNotFoundException : DomainException { public EnrollmentNotFoundException(int enrollmentId) : base("ENROLLMENT_NOT_FOUND", $"Enrollment {enrollmentId} was not found") { } } +/// +/// Thrown when a student attempts to enroll in a subject they're already enrolled in. +/// public class DuplicateEnrollmentException : DomainException { public DuplicateEnrollmentException(int studentId, int subjectId) : base("DUPLICATE_ENROLLMENT", $"Student {studentId} is already enrolled in subject {subjectId}") { } } + +/// +/// Thrown when attempting to assign more than 2 subjects to a professor. +/// Business rule: Each professor teaches exactly 2 subjects. +/// +public class ProfessorMaxSubjectsExceededException : DomainException +{ + public ProfessorMaxSubjectsExceededException(int professorId) + : base("PROFESSOR_MAX_SUBJECTS", $"Professor {professorId} already has the maximum of 2 subjects") { } +} + +/// +/// Thrown when a requested professor is not found. +/// +public class ProfessorNotFoundException : DomainException +{ + public ProfessorNotFoundException(int professorId) + : base("PROFESSOR_NOT_FOUND", $"Professor {professorId} was not found") { } +} diff --git a/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs b/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs index 71af85f..bd21c72 100644 --- a/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs +++ b/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs @@ -1,16 +1,52 @@ namespace Domain.Ports.Repositories; using Domain.Entities; +using Domain.ReadModels; +/// +/// Repository interface for enrollment persistence operations. +/// public interface IEnrollmentRepository { + /// + /// Gets an enrollment by its unique identifier. + /// Task GetByIdAsync(int id, CancellationToken ct = default); + + /// + /// Gets an enrollment by student and subject combination. + /// Task GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default); + + /// + /// Gets all enrollments for a specific student with related entities. + /// Task> GetByStudentIdAsync(int studentId, CancellationToken ct = default); + + /// + /// Gets all enrollments for a specific subject with related entities. + /// Task> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default); - Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default); - Task>> GetClassmatesBatchAsync( + + /// + /// Gets classmates (other students) enrolled in the same subject. + /// + Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default); + + /// + /// Batch query to get classmates for multiple subjects in a single database call. + /// Eliminates N+1 query problem when loading classmates for all enrolled subjects. + /// + Task>> GetClassmatesBatchAsync( int studentId, IEnumerable subjectIds, CancellationToken ct = default); + + /// + /// Adds a new enrollment to the context. + /// void Add(Enrollment enrollment); + + /// + /// Removes an enrollment from the context. + /// void Delete(Enrollment enrollment); } diff --git a/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs b/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs index 4aac2e6..412166a 100644 --- a/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs +++ b/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs @@ -3,13 +3,29 @@ namespace Domain.Ports.Repositories; using Domain.Entities; using System.Linq.Expressions; +/// +/// Repository interface for professor persistence operations. +/// public interface IProfessorRepository { + /// + /// Gets a professor by ID without related entities. + /// Task GetByIdAsync(int id, CancellationToken ct = default); + + /// + /// Gets all professors without related entities. + /// Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Gets all professors with their subjects. + /// Task> GetAllWithSubjectsAsync(CancellationToken ct = default); - // Projection support + /// + /// Gets all professors with custom projection to avoid over-fetching. + /// Task> GetAllProjectedAsync( Expression> selector, CancellationToken ct = default); diff --git a/src/backend/Domain/Ports/Repositories/IStudentRepository.cs b/src/backend/Domain/Ports/Repositories/IStudentRepository.cs index e58c4ab..29b9243 100644 --- a/src/backend/Domain/Ports/Repositories/IStudentRepository.cs +++ b/src/backend/Domain/Ports/Repositories/IStudentRepository.cs @@ -3,32 +3,85 @@ namespace Domain.Ports.Repositories; using Domain.Entities; using System.Linq.Expressions; +/// +/// Repository interface for student persistence operations. +/// public interface IStudentRepository { + /// + /// Gets a student by ID without related entities. + /// Task GetByIdAsync(int id, CancellationToken ct = default); + + /// + /// Gets a student by ID with enrollments and related subjects. + /// Task GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default); + + /// + /// Gets all students without related entities. + /// Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Gets all students with enrollments and related subjects. + /// Task> GetAllWithEnrollmentsAsync(CancellationToken ct = default); + + /// + /// Checks if a student exists by ID. + /// Task ExistsAsync(int id, CancellationToken ct = default); + + /// + /// Checks if an email is already in use, optionally excluding a student. + /// + /// The email to check. + /// Optional student ID to exclude from the check. + /// Cancellation token. Task EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default); - // Projection support for optimized queries + /// + /// Gets all students with custom projection to avoid over-fetching. + /// Task> GetAllProjectedAsync( Expression> selector, CancellationToken ct = default); + + /// + /// Gets a student by ID with custom projection. + /// Task GetByIdProjectedAsync( int id, Expression> selector, CancellationToken ct = default); - // Keyset pagination (more efficient than OFFSET) + /// + /// Gets students with keyset pagination (more efficient than OFFSET). + /// + /// Projection selector. + /// Cursor: ID after which to start. + /// Number of items per page. + /// Cancellation token. + /// Items, next cursor (if any), and total count. Task<(IReadOnlyList Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync( Expression> selector, int? afterId = null, int pageSize = 10, CancellationToken ct = default); + /// + /// Adds a new student to the context. + /// void Add(Student student); + + /// + /// Marks a student as modified in the context. + /// void Update(Student student); + + /// + /// Removes a student from the context. + /// void Delete(Student student); } diff --git a/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs b/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs index b62ac65..e5e23b6 100644 --- a/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs +++ b/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs @@ -3,15 +3,39 @@ namespace Domain.Ports.Repositories; using Domain.Entities; using System.Linq.Expressions; +/// +/// Repository interface for subject persistence operations. +/// public interface ISubjectRepository { + /// + /// Gets a subject by ID without related entities. + /// Task GetByIdAsync(int id, CancellationToken ct = default); + + /// + /// Gets a subject by ID with its professor. + /// Task GetByIdWithProfessorAsync(int id, CancellationToken ct = default); + + /// + /// Gets all subjects without related entities. + /// Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Gets all subjects with their professors. + /// Task> GetAllWithProfessorsAsync(CancellationToken ct = default); + + /// + /// Gets subjects available for a student (not enrolled, no professor conflict). + /// Task> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default); - // Projection support + /// + /// Gets all subjects with custom projection to avoid over-fetching. + /// Task> GetAllProjectedAsync( Expression> selector, CancellationToken ct = default); diff --git a/src/backend/Domain/ReadModels/ClassmateInfo.cs b/src/backend/Domain/ReadModels/ClassmateInfo.cs new file mode 100644 index 0000000..78bd34c --- /dev/null +++ b/src/backend/Domain/ReadModels/ClassmateInfo.cs @@ -0,0 +1,9 @@ +namespace Domain.ReadModels; + +/// +/// Minimal read-only projection of a student for classmate queries. +/// Used to avoid loading full Student entities when only Id and Name are needed. +/// +/// The student's unique identifier. +/// The student's full name. +public readonly record struct ClassmateInfo(int StudentId, string Name); diff --git a/src/backend/Domain/Services/EnrollmentDomainService.cs b/src/backend/Domain/Services/EnrollmentDomainService.cs index 22cc7f2..62a575c 100644 --- a/src/backend/Domain/Services/EnrollmentDomainService.cs +++ b/src/backend/Domain/Services/EnrollmentDomainService.cs @@ -3,8 +3,20 @@ namespace Domain.Services; using Domain.Entities; using Domain.Exceptions; +/// +/// Domain service for enrollment business rules. +/// Contains validation logic that spans multiple aggregates. +/// public class EnrollmentDomainService { + /// + /// Validates that a student can enroll in a subject. + /// + /// The student attempting to enroll. + /// The subject to enroll in. + /// Student has reached max enrollments. + /// Student already has a subject with this professor. + /// Student is already enrolled in this subject. public void ValidateEnrollment(Student student, Subject subject) { if (!student.CanEnroll()) @@ -17,6 +29,12 @@ public class EnrollmentDomainService throw new DuplicateEnrollmentException(student.Id, subject.Id); } + /// + /// Creates an enrollment after validating business rules. + /// + /// The student to enroll. + /// The subject to enroll in. + /// The created enrollment. public Enrollment CreateEnrollment(Student student, Subject subject) { ValidateEnrollment(student, subject); @@ -26,4 +44,30 @@ public class EnrollmentDomainService return enrollment; } + + /// + /// Checks if a subject is available for enrollment by a student. + /// Returns availability status and reason without throwing exceptions. + /// + /// The student checking availability. + /// The subject to check. + /// A tuple with availability status and optional reason if unavailable. + public (bool IsAvailable, string? Reason) CheckSubjectAvailability( + Student student, + Subject subject) + { + // Already enrolled in this subject + if (student.Enrollments.Any(e => e.SubjectId == subject.Id)) + return (false, "Already enrolled"); + + // Maximum enrollments reached + if (!student.CanEnroll()) + return (false, "Maximum 3 subjects reached"); + + // Already has a subject with this professor + if (student.HasProfessor(subject.ProfessorId)) + return (false, "Already have a subject with this professor"); + + return (true, null); + } } diff --git a/tests/Application.Tests/Application.Tests.csproj b/tests/Application.Tests/Application.Tests.csproj index 6ee6157..3bf834b 100644 --- a/tests/Application.Tests/Application.Tests.csproj +++ b/tests/Application.Tests/Application.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs b/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs index 3d22e9a..ea5acb2 100644 --- a/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs +++ b/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs @@ -4,6 +4,7 @@ using Application.Enrollments.Queries; using Domain.Entities; using Domain.Exceptions; using Domain.Ports.Repositories; +using Domain.ReadModels; using Domain.ValueObjects; using FluentAssertions; using NSubstitute; @@ -84,12 +85,10 @@ public class GetClassmatesQueryTests _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 classmate1 = new ClassmateInfo(2, "Jane Doe"); + var classmate2 = new ClassmateInfo(3, "Bob Smith"); - var batchResult = new Dictionary> + var batchResult = new Dictionary> { [1] = [classmate1], [2] = [classmate2] @@ -129,7 +128,7 @@ public class GetClassmatesQueryTests _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any()) .Returns(student); - var emptyBatchResult = new Dictionary>(); + var emptyBatchResult = new Dictionary>(); _enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any>(), Arg.Any()) .Returns(emptyBatchResult); diff --git a/tests/Application.Tests/Subjects/SubjectQueriesTests.cs b/tests/Application.Tests/Subjects/SubjectQueriesTests.cs index 1eb8445..653aa6b 100644 --- a/tests/Application.Tests/Subjects/SubjectQueriesTests.cs +++ b/tests/Application.Tests/Subjects/SubjectQueriesTests.cs @@ -92,13 +92,15 @@ public class GetAvailableSubjectsQueryTests { private readonly IStudentRepository _studentRepository; private readonly ISubjectRepository _subjectRepository; + private readonly Domain.Services.EnrollmentDomainService _enrollmentDomainService; private readonly GetAvailableSubjectsHandler _handler; public GetAvailableSubjectsQueryTests() { _studentRepository = Substitute.For(); _subjectRepository = Substitute.For(); - _handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository); + _enrollmentDomainService = new Domain.Services.EnrollmentDomainService(); + _handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository, _enrollmentDomainService); } [Fact] diff --git a/tests/Common/Builders/EnrollmentBuilder.cs b/tests/Common/Builders/EnrollmentBuilder.cs new file mode 100644 index 0000000..d55d59a --- /dev/null +++ b/tests/Common/Builders/EnrollmentBuilder.cs @@ -0,0 +1,74 @@ +namespace Common.Builders; + +using Domain.Entities; + +/// +/// Builder pattern for creating Enrollment test objects. +/// +public class EnrollmentBuilder +{ + private int _id = 1; + private int _studentId = 1; + private int _subjectId = 1; + private Student? _student; + private Subject? _subject; + private DateTime? _enrolledAt; + + public EnrollmentBuilder WithId(int id) + { + _id = id; + return this; + } + + public EnrollmentBuilder WithStudentId(int studentId) + { + _studentId = studentId; + return this; + } + + public EnrollmentBuilder WithSubjectId(int subjectId) + { + _subjectId = subjectId; + return this; + } + + public EnrollmentBuilder WithStudent(Student student) + { + _student = student; + _studentId = student.Id; + return this; + } + + public EnrollmentBuilder WithSubject(Subject subject) + { + _subject = subject; + _subjectId = subject.Id; + return this; + } + + public EnrollmentBuilder WithEnrolledAt(DateTime enrolledAt) + { + _enrolledAt = enrolledAt; + return this; + } + + public Enrollment Build() + { + var enrollment = new Enrollment(_studentId, _subjectId); + SetProperty(enrollment, "Id", _id); + + if (_student is not null) + SetProperty(enrollment, "Student", _student); + + if (_subject is not null) + SetProperty(enrollment, "Subject", _subject); + + if (_enrolledAt.HasValue) + SetProperty(enrollment, "EnrolledAt", _enrolledAt.Value); + + return enrollment; + } + + private static void SetProperty(T obj, string propertyName, object value) where T : class => + typeof(T).GetProperty(propertyName)!.SetValue(obj, value); +} diff --git a/tests/Common/Builders/ProfessorBuilder.cs b/tests/Common/Builders/ProfessorBuilder.cs new file mode 100644 index 0000000..2471ed7 --- /dev/null +++ b/tests/Common/Builders/ProfessorBuilder.cs @@ -0,0 +1,50 @@ +namespace Common.Builders; + +using Domain.Entities; + +/// +/// Builder pattern for creating Professor test objects. +/// +public class ProfessorBuilder +{ + private int _id = 1; + private string _name = "Prof. Smith"; + private readonly List _subjects = []; + + public ProfessorBuilder WithId(int id) + { + _id = id; + return this; + } + + public ProfessorBuilder WithName(string name) + { + _name = name; + return this; + } + + public ProfessorBuilder WithSubject(Subject subject) + { + _subjects.Add(subject); + return this; + } + + public Professor Build() + { + var professor = new Professor(_name); + SetProperty(professor, "Id", _id); + + // Access private _subjects field to add test subjects + var subjectsList = (List)typeof(Professor) + .GetField("_subjects", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(professor)!; + + foreach (var subject in _subjects) + subjectsList.Add(subject); + + return professor; + } + + private static void SetProperty(T obj, string propertyName, object value) where T : class => + typeof(T).GetProperty(propertyName)!.SetValue(obj, value); +} diff --git a/tests/Common/Builders/StudentBuilder.cs b/tests/Common/Builders/StudentBuilder.cs new file mode 100644 index 0000000..78ee59e --- /dev/null +++ b/tests/Common/Builders/StudentBuilder.cs @@ -0,0 +1,78 @@ +namespace Common.Builders; + +using Domain.Entities; +using Domain.ValueObjects; + +/// +/// Builder pattern for creating Student test objects. +/// Encapsulates reflection-based ID setting for cleaner tests. +/// +public class StudentBuilder +{ + private int _id = 1; + private string _name = "John Doe"; + private string _email = "john@test.com"; + private readonly List<(Subject Subject, int EnrollmentId)> _enrollments = []; + + public StudentBuilder WithId(int id) + { + _id = id; + return this; + } + + public StudentBuilder WithName(string name) + { + _name = name; + return this; + } + + public StudentBuilder WithEmail(string email) + { + _email = email; + return this; + } + + public StudentBuilder WithEnrollment(Subject subject, int enrollmentId = 0) + { + _enrollments.Add((subject, enrollmentId)); + return this; + } + + public StudentBuilder WithEnrollments(int count) + { + for (int i = 0; i < count; i++) + { + var subject = new SubjectBuilder() + .WithId(i + 100) + .WithName($"Subject{i}") + .WithProfessorId(i + 1) + .WithProfessor(new ProfessorBuilder() + .WithId(i + 1) + .WithName($"Prof{i}") + .Build()) + .Build(); + _enrollments.Add((subject, i + 1)); + } + return this; + } + + public Student Build() + { + var student = new Student(_name, Email.Create(_email)); + SetProperty(student, "Id", _id); + + foreach (var (subject, enrollmentId) in _enrollments) + { + var enrollment = new Enrollment(student.Id, subject.Id); + if (enrollmentId > 0) + SetProperty(enrollment, "Id", enrollmentId); + SetProperty(enrollment, "Subject", subject); + student.AddEnrollment(enrollment); + } + + return student; + } + + private static void SetProperty(T obj, string propertyName, object value) where T : class => + typeof(T).GetProperty(propertyName)!.SetValue(obj, value); +} diff --git a/tests/Common/Builders/SubjectBuilder.cs b/tests/Common/Builders/SubjectBuilder.cs new file mode 100644 index 0000000..8f672fc --- /dev/null +++ b/tests/Common/Builders/SubjectBuilder.cs @@ -0,0 +1,54 @@ +namespace Common.Builders; + +using Domain.Entities; + +/// +/// Builder pattern for creating Subject test objects. +/// Encapsulates reflection-based ID and navigation property setting. +/// +public class SubjectBuilder +{ + private int _id = 1; + private string _name = "Mathematics"; + private int _professorId = 1; + private Professor? _professor; + + public SubjectBuilder WithId(int id) + { + _id = id; + return this; + } + + public SubjectBuilder WithName(string name) + { + _name = name; + return this; + } + + public SubjectBuilder WithProfessorId(int professorId) + { + _professorId = professorId; + return this; + } + + public SubjectBuilder WithProfessor(Professor professor) + { + _professor = professor; + _professorId = professor.Id; + return this; + } + + public Subject Build() + { + var subject = new Subject(_name, _professorId); + SetProperty(subject, "Id", _id); + + if (_professor is not null) + SetProperty(subject, "Professor", _professor); + + return subject; + } + + private static void SetProperty(T obj, string propertyName, object value) where T : class => + typeof(T).GetProperty(propertyName)!.SetValue(obj, value); +} diff --git a/tests/Common/Common.csproj b/tests/Common/Common.csproj new file mode 100644 index 0000000..17c4ae6 --- /dev/null +++ b/tests/Common/Common.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/tests/Domain.Tests/Domain.Tests.csproj b/tests/Domain.Tests/Domain.Tests.csproj index 17f587b..b0afc20 100644 --- a/tests/Domain.Tests/Domain.Tests.csproj +++ b/tests/Domain.Tests/Domain.Tests.csproj @@ -24,6 +24,7 @@ +