refactor(backend): add validation patterns, query validators, and test builders
- Add centralized ValidationPatterns with XSS protection and name regex - Add query validators (GetClassmates, GetStudentById, GetStudentsPaged, GetAvailableSubjects) - Add ClassmateInfo read model for optimized query projections - Add test builders (Student, Subject, Professor, Enrollment) for cleaner tests - Improve repository interfaces with XML documentation - Refactor EnrollmentRepository for better performance - Update EnrollmentDomainService with additional validation helpers
This commit is contained in:
parent
891d177b8c
commit
9d8c6f0331
|
|
@ -3,8 +3,12 @@ namespace Adapters.Driven.Persistence.Repositories;
|
||||||
using Adapters.Driven.Persistence.Context;
|
using Adapters.Driven.Persistence.Context;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
|
using Domain.ReadModels;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IEnrollmentRepository"/>.
|
||||||
|
/// </summary>
|
||||||
public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
|
public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
|
||||||
{
|
{
|
||||||
public async Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
public async Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||||
|
|
@ -29,45 +33,32 @@ public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Student>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) =>
|
public async Task<IReadOnlyList<ClassmateInfo>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) =>
|
||||||
await context.Enrollments
|
await context.Enrollments
|
||||||
.Where(e => e.SubjectId == subjectId && e.StudentId != studentId)
|
.Where(e => e.SubjectId == subjectId && e.StudentId != studentId)
|
||||||
.Select(e => e.Student)
|
.Select(e => new ClassmateInfo(e.Student.Id, e.Student.Name))
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
public async Task<IReadOnlyDictionary<int, IReadOnlyList<Student>>> GetClassmatesBatchAsync(
|
public async Task<IReadOnlyDictionary<int, IReadOnlyList<ClassmateInfo>>> GetClassmatesBatchAsync(
|
||||||
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default)
|
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var subjectIdList = subjectIds.ToList();
|
var subjectIdList = subjectIds.ToList();
|
||||||
if (subjectIdList.Count == 0)
|
if (subjectIdList.Count == 0)
|
||||||
return new Dictionary<int, IReadOnlyList<Student>>();
|
return new Dictionary<int, IReadOnlyList<ClassmateInfo>>();
|
||||||
|
|
||||||
// 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
|
var enrollments = await context.Enrollments
|
||||||
.Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId)
|
.Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId)
|
||||||
.Select(e => new { e.SubjectId, e.Student.Id, e.Student.Name })
|
.Select(e => new { e.SubjectId, Classmate = new ClassmateInfo(e.Student.Id, e.Student.Name) })
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// Group by SubjectId and project to Student (minimal data needed)
|
// Group by SubjectId
|
||||||
return enrollments
|
return enrollments
|
||||||
.GroupBy(e => e.SubjectId)
|
.GroupBy(e => e.SubjectId)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => (IReadOnlyList<Student>)g
|
g => (IReadOnlyList<ClassmateInfo>)g.Select(e => e.Classmate).ToList());
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment);
|
public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
namespace Application.Common;
|
||||||
|
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared validation patterns and constants for input validation.
|
||||||
|
/// Used across validators to ensure consistency and DRY principle.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class ValidationPatterns
|
||||||
|
{
|
||||||
|
#region Name Validation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum length for student/person names.
|
||||||
|
/// </summary>
|
||||||
|
public const int MinNameLength = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum length for student/person names.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxNameLength = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pattern for valid name characters.
|
||||||
|
/// Allows: Unicode letters, spaces, hyphens, apostrophes, and periods.
|
||||||
|
/// Examples: "Juan Pérez", "María O'Brien", "Jean-Pierre"
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)]
|
||||||
|
public static partial Regex SafeNamePattern();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Email Validation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum length for email addresses (RFC 5321).
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxEmailLength = 254;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Security Patterns
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pattern to detect potentially dangerous content (XSS, script injection).
|
||||||
|
/// Matches: HTML tags, javascript: protocol, event handlers (onclick, onload, etc.)
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
public static partial Regex DangerousContentPattern();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a string contains potentially dangerous content.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The string to check.</param>
|
||||||
|
/// <returns>True if the content is safe (no dangerous patterns found).</returns>
|
||||||
|
public static bool IsSafeContent(string? value) =>
|
||||||
|
string.IsNullOrEmpty(value) || !DangerousContentPattern().IsMatch(value);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ID Validation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error message for invalid entity ID.
|
||||||
|
/// </summary>
|
||||||
|
public const string InvalidIdMessage = "ID must be greater than 0";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,33 @@
|
||||||
namespace Application.Enrollments.Commands;
|
namespace Application.Enrollments.Commands;
|
||||||
|
|
||||||
|
using Application.Common;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="EnrollStudentCommand"/>.
|
||||||
|
/// Validates that student and subject IDs are valid.
|
||||||
|
/// </summary>
|
||||||
public class EnrollStudentValidator : AbstractValidator<EnrollStudentCommand>
|
public class EnrollStudentValidator : AbstractValidator<EnrollStudentCommand>
|
||||||
{
|
{
|
||||||
public EnrollStudentValidator()
|
public EnrollStudentValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.StudentId)
|
RuleFor(x => x.StudentId)
|
||||||
.GreaterThan(0).WithMessage("Invalid student ID");
|
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
|
||||||
|
|
||||||
RuleFor(x => x.SubjectId)
|
RuleFor(x => x.SubjectId)
|
||||||
.GreaterThan(0).WithMessage("Invalid subject ID");
|
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="UnenrollStudentCommand"/>.
|
||||||
|
/// Validates that enrollment ID is valid.
|
||||||
|
/// </summary>
|
||||||
public class UnenrollStudentValidator : AbstractValidator<UnenrollStudentCommand>
|
public class UnenrollStudentValidator : AbstractValidator<UnenrollStudentCommand>
|
||||||
{
|
{
|
||||||
public UnenrollStudentValidator()
|
public UnenrollStudentValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.EnrollmentId)
|
RuleFor(x => x.EnrollmentId)
|
||||||
.GreaterThan(0).WithMessage("Invalid enrollment ID");
|
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,16 @@ using Domain.Exceptions;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to retrieve classmates for all subjects a student is enrolled in.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="StudentId">The student's unique identifier.</param>
|
||||||
public record GetClassmatesQuery(int StudentId) : IRequest<IReadOnlyList<ClassmatesBySubjectDto>>;
|
public record GetClassmatesQuery(int StudentId) : IRequest<IReadOnlyList<ClassmatesBySubjectDto>>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for <see cref="GetClassmatesQuery"/>.
|
||||||
|
/// Uses batch query to avoid N+1 problem.
|
||||||
|
/// </summary>
|
||||||
public class GetClassmatesHandler(
|
public class GetClassmatesHandler(
|
||||||
IStudentRepository studentRepository,
|
IStudentRepository studentRepository,
|
||||||
IEnrollmentRepository enrollmentRepository)
|
IEnrollmentRepository enrollmentRepository)
|
||||||
|
|
@ -31,7 +39,7 @@ public class GetClassmatesHandler(
|
||||||
enrollment.SubjectId,
|
enrollment.SubjectId,
|
||||||
enrollment.Subject?.Name ?? "",
|
enrollment.Subject?.Name ?? "",
|
||||||
classmatesBySubject.TryGetValue(enrollment.SubjectId, out var classmates)
|
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();
|
)).ToList();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace Application.Enrollments.Queries;
|
||||||
|
|
||||||
|
using Application.Common;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="GetClassmatesQuery"/>.
|
||||||
|
/// Ensures the student ID is valid before querying classmates.
|
||||||
|
/// </summary>
|
||||||
|
public class GetClassmatesValidator : AbstractValidator<GetClassmatesQuery>
|
||||||
|
{
|
||||||
|
public GetClassmatesValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StudentId)
|
||||||
|
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,70 +1,72 @@
|
||||||
namespace Application.Students.Commands;
|
namespace Application.Students.Commands;
|
||||||
|
|
||||||
using System.Text.RegularExpressions;
|
using Application.Common;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
public partial class CreateStudentValidator : AbstractValidator<CreateStudentCommand>
|
/// <summary>
|
||||||
|
/// Validator for <see cref="CreateStudentCommand"/>.
|
||||||
|
/// Validates name format, email uniqueness, and prevents XSS attacks.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateStudentValidator : AbstractValidator<CreateStudentCommand>
|
||||||
{
|
{
|
||||||
[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)
|
public CreateStudentValidator(IStudentRepository studentRepository)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name)
|
RuleFor(x => x.Name)
|
||||||
.NotEmpty().WithMessage("Name is required")
|
.NotEmpty().WithMessage("Name is required")
|
||||||
.MinimumLength(3).WithMessage("Name must be at least 3 characters")
|
.MinimumLength(ValidationPatterns.MinNameLength)
|
||||||
.MaximumLength(100).WithMessage("Name must not exceed 100 characters")
|
.WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters")
|
||||||
.Matches(SafeNamePattern()).WithMessage("Name contains invalid characters")
|
.MaximumLength(ValidationPatterns.MaxNameLength)
|
||||||
.Must(NotContainDangerousContent).WithMessage("Name contains prohibited content");
|
.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)
|
RuleFor(x => x.Email)
|
||||||
.NotEmpty().WithMessage("Email is required")
|
.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")
|
.EmailAddress().WithMessage("Invalid email format")
|
||||||
.Must(NotContainDangerousContent).WithMessage("Email contains prohibited content")
|
.Must(ValidationPatterns.IsSafeContent)
|
||||||
|
.WithMessage("Email contains prohibited content")
|
||||||
.MustAsync(async (email, ct) =>
|
.MustAsync(async (email, ct) =>
|
||||||
!await studentRepository.EmailExistsAsync(email, null, ct))
|
!await studentRepository.EmailExistsAsync(email, null, ct))
|
||||||
.WithMessage("Email already exists");
|
.WithMessage("Email already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool NotContainDangerousContent(string? value) =>
|
|
||||||
string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class UpdateStudentValidator : AbstractValidator<UpdateStudentCommand>
|
/// <summary>
|
||||||
|
/// Validator for <see cref="UpdateStudentCommand"/>.
|
||||||
|
/// Validates name format, email uniqueness (excluding current student), and prevents XSS attacks.
|
||||||
|
/// </summary>
|
||||||
|
public class UpdateStudentValidator : AbstractValidator<UpdateStudentCommand>
|
||||||
{
|
{
|
||||||
[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)
|
public UpdateStudentValidator(IStudentRepository studentRepository)
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Id)
|
RuleFor(x => x.Id)
|
||||||
.GreaterThan(0).WithMessage("Invalid student ID");
|
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
|
||||||
|
|
||||||
RuleFor(x => x.Name)
|
RuleFor(x => x.Name)
|
||||||
.NotEmpty().WithMessage("Name is required")
|
.NotEmpty().WithMessage("Name is required")
|
||||||
.MinimumLength(3).WithMessage("Name must be at least 3 characters")
|
.MinimumLength(ValidationPatterns.MinNameLength)
|
||||||
.MaximumLength(100).WithMessage("Name must not exceed 100 characters")
|
.WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters")
|
||||||
.Matches(SafeNamePattern()).WithMessage("Name contains invalid characters")
|
.MaximumLength(ValidationPatterns.MaxNameLength)
|
||||||
.Must(NotContainDangerousContent).WithMessage("Name contains prohibited content");
|
.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)
|
RuleFor(x => x.Email)
|
||||||
.NotEmpty().WithMessage("Email is required")
|
.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")
|
.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) =>
|
.MustAsync(async (command, email, ct) =>
|
||||||
!await studentRepository.EmailExistsAsync(email, command.Id, ct))
|
!await studentRepository.EmailExistsAsync(email, command.Id, ct))
|
||||||
.WithMessage("Email already exists");
|
.WithMessage("Email already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool NotContainDangerousContent(string? value) =>
|
|
||||||
string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace Application.Students.Queries;
|
||||||
|
|
||||||
|
using Application.Common;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="GetStudentByIdQuery"/>.
|
||||||
|
/// Ensures the student ID is valid before querying.
|
||||||
|
/// </summary>
|
||||||
|
public class GetStudentByIdValidator : AbstractValidator<GetStudentByIdQuery>
|
||||||
|
{
|
||||||
|
public GetStudentByIdValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace Application.Students.Queries;
|
||||||
|
|
||||||
|
using Application.Common;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="GetStudentsPagedQuery"/>.
|
||||||
|
/// Validates pagination parameters.
|
||||||
|
/// </summary>
|
||||||
|
public class GetStudentsPagedValidator : AbstractValidator<GetStudentsPagedQuery>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
namespace Application.Subjects.Queries;
|
namespace Application.Subjects.Queries;
|
||||||
|
|
||||||
using Application.Subjects.DTOs;
|
using Application.Subjects.DTOs;
|
||||||
using Domain.Entities;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
|
using Domain.Services;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get all subjects with availability status for a specific student.
|
||||||
|
/// </summary>
|
||||||
public record GetAvailableSubjectsQuery(int StudentId) : IRequest<IReadOnlyList<AvailableSubjectDto>>;
|
public record GetAvailableSubjectsQuery(int StudentId) : IRequest<IReadOnlyList<AvailableSubjectDto>>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for <see cref="GetAvailableSubjectsQuery"/>.
|
||||||
|
/// Uses domain service for availability logic.
|
||||||
|
/// </summary>
|
||||||
public class GetAvailableSubjectsHandler(
|
public class GetAvailableSubjectsHandler(
|
||||||
IStudentRepository studentRepository,
|
IStudentRepository studentRepository,
|
||||||
ISubjectRepository subjectRepository)
|
ISubjectRepository subjectRepository,
|
||||||
|
EnrollmentDomainService enrollmentDomainService)
|
||||||
: IRequestHandler<GetAvailableSubjectsQuery, IReadOnlyList<AvailableSubjectDto>>
|
: IRequestHandler<GetAvailableSubjectsQuery, IReadOnlyList<AvailableSubjectDto>>
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<AvailableSubjectDto>> Handle(
|
public async Task<IReadOnlyList<AvailableSubjectDto>> Handle(
|
||||||
|
|
@ -22,41 +30,17 @@ public class GetAvailableSubjectsHandler(
|
||||||
|
|
||||||
var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct);
|
var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct);
|
||||||
|
|
||||||
var enrolledSubjectIds = student.Enrollments.Select(e => e.SubjectId).ToHashSet();
|
return allSubjects.Select(subject =>
|
||||||
var enrolledProfessorIds = student.Enrollments
|
|
||||||
.Select(e => e.Subject?.ProfessorId ?? 0)
|
|
||||||
.Where(id => id > 0)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
return allSubjects.Select(s =>
|
|
||||||
{
|
{
|
||||||
var (isAvailable, reason) = GetAvailability(s, student, enrolledSubjectIds, enrolledProfessorIds);
|
var (isAvailable, reason) = enrollmentDomainService.CheckSubjectAvailability(student, subject);
|
||||||
return new AvailableSubjectDto(
|
return new AvailableSubjectDto(
|
||||||
s.Id,
|
subject.Id,
|
||||||
s.Name,
|
subject.Name,
|
||||||
s.Credits,
|
subject.Credits,
|
||||||
s.ProfessorId,
|
subject.ProfessorId,
|
||||||
s.Professor?.Name ?? "",
|
subject.Professor?.Name ?? "",
|
||||||
isAvailable,
|
isAvailable,
|
||||||
reason);
|
reason);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (bool IsAvailable, string? Reason) GetAvailability(
|
|
||||||
Subject subject,
|
|
||||||
Student student,
|
|
||||||
HashSet<int> enrolledSubjectIds,
|
|
||||||
HashSet<int> 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace Application.Subjects.Queries;
|
||||||
|
|
||||||
|
using Application.Common;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="GetAvailableSubjectsQuery"/>.
|
||||||
|
/// Ensures the student ID is valid before querying available subjects.
|
||||||
|
/// </summary>
|
||||||
|
public class GetAvailableSubjectsValidator : AbstractValidator<GetAvailableSubjectsQuery>
|
||||||
|
{
|
||||||
|
public GetAvailableSubjectsValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.StudentId)
|
||||||
|
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
namespace Domain.Exceptions;
|
namespace Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for all domain exceptions.
|
||||||
|
/// Provides a machine-readable code for client error handling.
|
||||||
|
/// </summary>
|
||||||
public abstract class DomainException : Exception
|
public abstract class DomainException : Exception
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Machine-readable error code for client handling.
|
||||||
|
/// </summary>
|
||||||
public string Code { get; }
|
public string Code { get; }
|
||||||
|
|
||||||
protected DomainException(string code, string message) : base(message)
|
protected DomainException(string code, string message) : base(message)
|
||||||
|
|
@ -10,38 +17,75 @@ public abstract class DomainException : Exception
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a student attempts to enroll in more than 3 subjects.
|
||||||
|
/// </summary>
|
||||||
public class MaxEnrollmentsExceededException : DomainException
|
public class MaxEnrollmentsExceededException : DomainException
|
||||||
{
|
{
|
||||||
public MaxEnrollmentsExceededException(int studentId)
|
public MaxEnrollmentsExceededException(int studentId)
|
||||||
: base("MAX_ENROLLMENTS", $"Student {studentId} cannot enroll in more than 3 subjects") { }
|
: base("MAX_ENROLLMENTS", $"Student {studentId} cannot enroll in more than 3 subjects") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a student attempts to enroll in a subject with a professor they already have.
|
||||||
|
/// </summary>
|
||||||
public class SameProfessorConstraintException : DomainException
|
public class SameProfessorConstraintException : DomainException
|
||||||
{
|
{
|
||||||
public SameProfessorConstraintException(int studentId, int professorId)
|
public SameProfessorConstraintException(int studentId, int professorId)
|
||||||
: base("SAME_PROFESSOR", $"Student {studentId} already has a subject with professor {professorId}") { }
|
: base("SAME_PROFESSOR", $"Student {studentId} already has a subject with professor {professorId}") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested student is not found.
|
||||||
|
/// </summary>
|
||||||
public class StudentNotFoundException : DomainException
|
public class StudentNotFoundException : DomainException
|
||||||
{
|
{
|
||||||
public StudentNotFoundException(int studentId)
|
public StudentNotFoundException(int studentId)
|
||||||
: base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { }
|
: base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested subject is not found.
|
||||||
|
/// </summary>
|
||||||
public class SubjectNotFoundException : DomainException
|
public class SubjectNotFoundException : DomainException
|
||||||
{
|
{
|
||||||
public SubjectNotFoundException(int subjectId)
|
public SubjectNotFoundException(int subjectId)
|
||||||
: base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { }
|
: base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested enrollment is not found.
|
||||||
|
/// </summary>
|
||||||
public class EnrollmentNotFoundException : DomainException
|
public class EnrollmentNotFoundException : DomainException
|
||||||
{
|
{
|
||||||
public EnrollmentNotFoundException(int enrollmentId)
|
public EnrollmentNotFoundException(int enrollmentId)
|
||||||
: base("ENROLLMENT_NOT_FOUND", $"Enrollment {enrollmentId} was not found") { }
|
: base("ENROLLMENT_NOT_FOUND", $"Enrollment {enrollmentId} was not found") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a student attempts to enroll in a subject they're already enrolled in.
|
||||||
|
/// </summary>
|
||||||
public class DuplicateEnrollmentException : DomainException
|
public class DuplicateEnrollmentException : DomainException
|
||||||
{
|
{
|
||||||
public DuplicateEnrollmentException(int studentId, int subjectId)
|
public DuplicateEnrollmentException(int studentId, int subjectId)
|
||||||
: base("DUPLICATE_ENROLLMENT", $"Student {studentId} is already enrolled in subject {subjectId}") { }
|
: base("DUPLICATE_ENROLLMENT", $"Student {studentId} is already enrolled in subject {subjectId}") { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when attempting to assign more than 2 subjects to a professor.
|
||||||
|
/// Business rule: Each professor teaches exactly 2 subjects.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfessorMaxSubjectsExceededException : DomainException
|
||||||
|
{
|
||||||
|
public ProfessorMaxSubjectsExceededException(int professorId)
|
||||||
|
: base("PROFESSOR_MAX_SUBJECTS", $"Professor {professorId} already has the maximum of 2 subjects") { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested professor is not found.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfessorNotFoundException : DomainException
|
||||||
|
{
|
||||||
|
public ProfessorNotFoundException(int professorId)
|
||||||
|
: base("PROFESSOR_NOT_FOUND", $"Professor {professorId} was not found") { }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,52 @@
|
||||||
namespace Domain.Ports.Repositories;
|
namespace Domain.Ports.Repositories;
|
||||||
|
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Domain.ReadModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for enrollment persistence operations.
|
||||||
|
/// </summary>
|
||||||
public interface IEnrollmentRepository
|
public interface IEnrollmentRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an enrollment by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default);
|
Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an enrollment by student and subject combination.
|
||||||
|
/// </summary>
|
||||||
Task<Enrollment?> GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default);
|
Task<Enrollment?> GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all enrollments for a specific student with related entities.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Enrollment>> GetByStudentIdAsync(int studentId, CancellationToken ct = default);
|
Task<IReadOnlyList<Enrollment>> GetByStudentIdAsync(int studentId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all enrollments for a specific subject with related entities.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Enrollment>> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default);
|
Task<IReadOnlyList<Enrollment>> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<Student>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default);
|
|
||||||
Task<IReadOnlyDictionary<int, IReadOnlyList<Student>>> GetClassmatesBatchAsync(
|
/// <summary>
|
||||||
|
/// Gets classmates (other students) enrolled in the same subject.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ClassmateInfo>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<int, IReadOnlyList<ClassmateInfo>>> GetClassmatesBatchAsync(
|
||||||
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default);
|
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new enrollment to the context.
|
||||||
|
/// </summary>
|
||||||
void Add(Enrollment enrollment);
|
void Add(Enrollment enrollment);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an enrollment from the context.
|
||||||
|
/// </summary>
|
||||||
void Delete(Enrollment enrollment);
|
void Delete(Enrollment enrollment);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,29 @@ namespace Domain.Ports.Repositories;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for professor persistence operations.
|
||||||
|
/// </summary>
|
||||||
public interface IProfessorRepository
|
public interface IProfessorRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a professor by ID without related entities.
|
||||||
|
/// </summary>
|
||||||
Task<Professor?> GetByIdAsync(int id, CancellationToken ct = default);
|
Task<Professor?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all professors without related entities.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all professors with their subjects.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Professor>> GetAllWithSubjectsAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Professor>> GetAllWithSubjectsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
// Projection support
|
/// <summary>
|
||||||
|
/// Gets all professors with custom projection to avoid over-fetching.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||||
Expression<Func<Professor, TResult>> selector,
|
Expression<Func<Professor, TResult>> selector,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,85 @@ namespace Domain.Ports.Repositories;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for student persistence operations.
|
||||||
|
/// </summary>
|
||||||
public interface IStudentRepository
|
public interface IStudentRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a student by ID without related entities.
|
||||||
|
/// </summary>
|
||||||
Task<Student?> GetByIdAsync(int id, CancellationToken ct = default);
|
Task<Student?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a student by ID with enrollments and related subjects.
|
||||||
|
/// </summary>
|
||||||
Task<Student?> GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default);
|
Task<Student?> GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all students without related entities.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all students with enrollments and related subjects.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Student>> GetAllWithEnrollmentsAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Student>> GetAllWithEnrollmentsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a student exists by ID.
|
||||||
|
/// </summary>
|
||||||
Task<bool> ExistsAsync(int id, CancellationToken ct = default);
|
Task<bool> ExistsAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if an email is already in use, optionally excluding a student.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="email">The email to check.</param>
|
||||||
|
/// <param name="excludeId">Optional student ID to exclude from the check.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
Task<bool> EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default);
|
Task<bool> EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default);
|
||||||
|
|
||||||
// Projection support for optimized queries
|
/// <summary>
|
||||||
|
/// Gets all students with custom projection to avoid over-fetching.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||||
Expression<Func<Student, TResult>> selector,
|
Expression<Func<Student, TResult>> selector,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a student by ID with custom projection.
|
||||||
|
/// </summary>
|
||||||
Task<TResult?> GetByIdProjectedAsync<TResult>(
|
Task<TResult?> GetByIdProjectedAsync<TResult>(
|
||||||
int id,
|
int id,
|
||||||
Expression<Func<Student, TResult>> selector,
|
Expression<Func<Student, TResult>> selector,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
// Keyset pagination (more efficient than OFFSET)
|
/// <summary>
|
||||||
|
/// Gets students with keyset pagination (more efficient than OFFSET).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="selector">Projection selector.</param>
|
||||||
|
/// <param name="afterId">Cursor: ID after which to start.</param>
|
||||||
|
/// <param name="pageSize">Number of items per page.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>Items, next cursor (if any), and total count.</returns>
|
||||||
Task<(IReadOnlyList<TResult> Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync<TResult>(
|
Task<(IReadOnlyList<TResult> Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync<TResult>(
|
||||||
Expression<Func<Student, TResult>> selector,
|
Expression<Func<Student, TResult>> selector,
|
||||||
int? afterId = null,
|
int? afterId = null,
|
||||||
int pageSize = 10,
|
int pageSize = 10,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new student to the context.
|
||||||
|
/// </summary>
|
||||||
void Add(Student student);
|
void Add(Student student);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a student as modified in the context.
|
||||||
|
/// </summary>
|
||||||
void Update(Student student);
|
void Update(Student student);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a student from the context.
|
||||||
|
/// </summary>
|
||||||
void Delete(Student student);
|
void Delete(Student student);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,39 @@ namespace Domain.Ports.Repositories;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for subject persistence operations.
|
||||||
|
/// </summary>
|
||||||
public interface ISubjectRepository
|
public interface ISubjectRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a subject by ID without related entities.
|
||||||
|
/// </summary>
|
||||||
Task<Subject?> GetByIdAsync(int id, CancellationToken ct = default);
|
Task<Subject?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a subject by ID with its professor.
|
||||||
|
/// </summary>
|
||||||
Task<Subject?> GetByIdWithProfessorAsync(int id, CancellationToken ct = default);
|
Task<Subject?> GetByIdWithProfessorAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all subjects without related entities.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all subjects with their professors.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Subject>> GetAllWithProfessorsAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Subject>> GetAllWithProfessorsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets subjects available for a student (not enrolled, no professor conflict).
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<Subject>> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default);
|
Task<IReadOnlyList<Subject>> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default);
|
||||||
|
|
||||||
// Projection support
|
/// <summary>
|
||||||
|
/// Gets all subjects with custom projection to avoid over-fetching.
|
||||||
|
/// </summary>
|
||||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||||
Expression<Func<Subject, TResult>> selector,
|
Expression<Func<Subject, TResult>> selector,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace Domain.ReadModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal read-only projection of a student for classmate queries.
|
||||||
|
/// Used to avoid loading full Student entities when only Id and Name are needed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="StudentId">The student's unique identifier.</param>
|
||||||
|
/// <param name="Name">The student's full name.</param>
|
||||||
|
public readonly record struct ClassmateInfo(int StudentId, string Name);
|
||||||
|
|
@ -3,8 +3,20 @@ namespace Domain.Services;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain service for enrollment business rules.
|
||||||
|
/// Contains validation logic that spans multiple aggregates.
|
||||||
|
/// </summary>
|
||||||
public class EnrollmentDomainService
|
public class EnrollmentDomainService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that a student can enroll in a subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student attempting to enroll.</param>
|
||||||
|
/// <param name="subject">The subject to enroll in.</param>
|
||||||
|
/// <exception cref="MaxEnrollmentsExceededException">Student has reached max enrollments.</exception>
|
||||||
|
/// <exception cref="SameProfessorConstraintException">Student already has a subject with this professor.</exception>
|
||||||
|
/// <exception cref="DuplicateEnrollmentException">Student is already enrolled in this subject.</exception>
|
||||||
public void ValidateEnrollment(Student student, Subject subject)
|
public void ValidateEnrollment(Student student, Subject subject)
|
||||||
{
|
{
|
||||||
if (!student.CanEnroll())
|
if (!student.CanEnroll())
|
||||||
|
|
@ -17,6 +29,12 @@ public class EnrollmentDomainService
|
||||||
throw new DuplicateEnrollmentException(student.Id, subject.Id);
|
throw new DuplicateEnrollmentException(student.Id, subject.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an enrollment after validating business rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student to enroll.</param>
|
||||||
|
/// <param name="subject">The subject to enroll in.</param>
|
||||||
|
/// <returns>The created enrollment.</returns>
|
||||||
public Enrollment CreateEnrollment(Student student, Subject subject)
|
public Enrollment CreateEnrollment(Student student, Subject subject)
|
||||||
{
|
{
|
||||||
ValidateEnrollment(student, subject);
|
ValidateEnrollment(student, subject);
|
||||||
|
|
@ -26,4 +44,30 @@ public class EnrollmentDomainService
|
||||||
|
|
||||||
return enrollment;
|
return enrollment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a subject is available for enrollment by a student.
|
||||||
|
/// Returns availability status and reason without throwing exceptions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student checking availability.</param>
|
||||||
|
/// <param name="subject">The subject to check.</param>
|
||||||
|
/// <returns>A tuple with availability status and optional reason if unavailable.</returns>
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\backend\Application\Application.csproj" />
|
<ProjectReference Include="..\..\src\backend\Application\Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Common\Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Application.Enrollments.Queries;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
|
using Domain.ReadModels;
|
||||||
using Domain.ValueObjects;
|
using Domain.ValueObjects;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
|
@ -84,12 +85,10 @@ public class GetClassmatesQueryTests
|
||||||
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
|
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
|
||||||
.Returns(student);
|
.Returns(student);
|
||||||
|
|
||||||
var classmate1 = new Student("Jane Doe", Email.Create("jane@test.com"));
|
var classmate1 = new ClassmateInfo(2, "Jane Doe");
|
||||||
var classmate2 = new Student("Bob Smith", Email.Create("bob@test.com"));
|
var classmate2 = new ClassmateInfo(3, "Bob Smith");
|
||||||
SetEntityId(classmate1, 2);
|
|
||||||
SetEntityId(classmate2, 3);
|
|
||||||
|
|
||||||
var batchResult = new Dictionary<int, IReadOnlyList<Student>>
|
var batchResult = new Dictionary<int, IReadOnlyList<ClassmateInfo>>
|
||||||
{
|
{
|
||||||
[1] = [classmate1],
|
[1] = [classmate1],
|
||||||
[2] = [classmate2]
|
[2] = [classmate2]
|
||||||
|
|
@ -129,7 +128,7 @@ public class GetClassmatesQueryTests
|
||||||
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
|
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
|
||||||
.Returns(student);
|
.Returns(student);
|
||||||
|
|
||||||
var emptyBatchResult = new Dictionary<int, IReadOnlyList<Student>>();
|
var emptyBatchResult = new Dictionary<int, IReadOnlyList<ClassmateInfo>>();
|
||||||
_enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>())
|
_enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(emptyBatchResult);
|
.Returns(emptyBatchResult);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,15 @@ public class GetAvailableSubjectsQueryTests
|
||||||
{
|
{
|
||||||
private readonly IStudentRepository _studentRepository;
|
private readonly IStudentRepository _studentRepository;
|
||||||
private readonly ISubjectRepository _subjectRepository;
|
private readonly ISubjectRepository _subjectRepository;
|
||||||
|
private readonly Domain.Services.EnrollmentDomainService _enrollmentDomainService;
|
||||||
private readonly GetAvailableSubjectsHandler _handler;
|
private readonly GetAvailableSubjectsHandler _handler;
|
||||||
|
|
||||||
public GetAvailableSubjectsQueryTests()
|
public GetAvailableSubjectsQueryTests()
|
||||||
{
|
{
|
||||||
_studentRepository = Substitute.For<IStudentRepository>();
|
_studentRepository = Substitute.For<IStudentRepository>();
|
||||||
_subjectRepository = Substitute.For<ISubjectRepository>();
|
_subjectRepository = Substitute.For<ISubjectRepository>();
|
||||||
_handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository);
|
_enrollmentDomainService = new Domain.Services.EnrollmentDomainService();
|
||||||
|
_handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository, _enrollmentDomainService);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
namespace Common.Builders;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builder pattern for creating Enrollment test objects.
|
||||||
|
/// </summary>
|
||||||
|
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>(T obj, string propertyName, object value) where T : class =>
|
||||||
|
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
namespace Common.Builders;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builder pattern for creating Professor test objects.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfessorBuilder
|
||||||
|
{
|
||||||
|
private int _id = 1;
|
||||||
|
private string _name = "Prof. Smith";
|
||||||
|
private readonly List<Subject> _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<Subject>)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>(T obj, string propertyName, object value) where T : class =>
|
||||||
|
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
namespace Common.Builders;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.ValueObjects;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builder pattern for creating Student test objects.
|
||||||
|
/// Encapsulates reflection-based ID setting for cleaner tests.
|
||||||
|
/// </summary>
|
||||||
|
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>(T obj, string propertyName, object value) where T : class =>
|
||||||
|
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
namespace Common.Builders;
|
||||||
|
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builder pattern for creating Subject test objects.
|
||||||
|
/// Encapsulates reflection-based ID and navigation property setting.
|
||||||
|
/// </summary>
|
||||||
|
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>(T obj, string propertyName, object value) where T : class =>
|
||||||
|
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" />
|
<ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Common\Common.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue