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 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
815ebf5673
commit
4ede4d4cef
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IEnrollmentRepository"/>.
|
||||
/// </summary>
|
||||
public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
|
||||
{
|
||||
public async Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default) =>
|
||||
|
|
@ -29,45 +33,32 @@ public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
|
|||
.AsNoTracking()
|
||||
.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
|
||||
.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<IReadOnlyDictionary<int, IReadOnlyList<Student>>> GetClassmatesBatchAsync(
|
||||
public async Task<IReadOnlyDictionary<int, IReadOnlyList<ClassmateInfo>>> GetClassmatesBatchAsync(
|
||||
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default)
|
||||
{
|
||||
var subjectIdList = subjectIds.ToList();
|
||||
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
|
||||
.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<Student>)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<ClassmateInfo>)g.Select(e => e.Classmate).ToList());
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
using Application.Common;
|
||||
using FluentValidation;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for <see cref="EnrollStudentCommand"/>.
|
||||
/// Validates that student and subject IDs are valid.
|
||||
/// </summary>
|
||||
public class EnrollStudentValidator : AbstractValidator<EnrollStudentCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for <see cref="UnenrollStudentCommand"/>.
|
||||
/// Validates that enrollment ID is valid.
|
||||
/// </summary>
|
||||
public class UnenrollStudentValidator : AbstractValidator<UnenrollStudentCommand>
|
||||
{
|
||||
public UnenrollStudentValidator()
|
||||
{
|
||||
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 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>>;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for <see cref="GetClassmatesQuery"/>.
|
||||
/// Uses batch query to avoid N+1 problem.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Application.Common;
|
||||
using Domain.Ports.Repositories;
|
||||
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)
|
||||
{
|
||||
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<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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
using Application.Subjects.DTOs;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Domain.Ports.Repositories;
|
||||
using Domain.Services;
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// Query to get all subjects with availability status for a specific student.
|
||||
/// </summary>
|
||||
public record GetAvailableSubjectsQuery(int StudentId) : IRequest<IReadOnlyList<AvailableSubjectDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for <see cref="GetAvailableSubjectsQuery"/>.
|
||||
/// Uses domain service for availability logic.
|
||||
/// </summary>
|
||||
public class GetAvailableSubjectsHandler(
|
||||
IStudentRepository studentRepository,
|
||||
ISubjectRepository subjectRepository)
|
||||
ISubjectRepository subjectRepository,
|
||||
EnrollmentDomainService enrollmentDomainService)
|
||||
: IRequestHandler<GetAvailableSubjectsQuery, IReadOnlyList<AvailableSubjectDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<AvailableSubjectDto>> 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<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;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all domain exceptions.
|
||||
/// Provides a machine-readable code for client error handling.
|
||||
/// </summary>
|
||||
public abstract class DomainException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable error code for client handling.
|
||||
/// </summary>
|
||||
public string Code { get; }
|
||||
|
||||
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 MaxEnrollmentsExceededException(int studentId)
|
||||
: 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 SameProfessorConstraintException(int studentId, int 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 StudentNotFoundException(int studentId)
|
||||
: base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a requested subject is not found.
|
||||
/// </summary>
|
||||
public class SubjectNotFoundException : DomainException
|
||||
{
|
||||
public SubjectNotFoundException(int subjectId)
|
||||
: base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a requested enrollment is not found.
|
||||
/// </summary>
|
||||
public class EnrollmentNotFoundException : DomainException
|
||||
{
|
||||
public EnrollmentNotFoundException(int enrollmentId)
|
||||
: 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 DuplicateEnrollmentException(int studentId, int 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;
|
||||
|
||||
using Domain.Entities;
|
||||
using Domain.ReadModels;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for enrollment persistence operations.
|
||||
/// </summary>
|
||||
public interface IEnrollmentRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an enrollment by its unique identifier.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all enrollments for a specific student with related entities.
|
||||
/// </summary>
|
||||
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<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);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new enrollment to the context.
|
||||
/// </summary>
|
||||
void Add(Enrollment enrollment);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an enrollment from the context.
|
||||
/// </summary>
|
||||
void Delete(Enrollment enrollment);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,29 @@ namespace Domain.Ports.Repositories;
|
|||
using Domain.Entities;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for professor persistence operations.
|
||||
/// </summary>
|
||||
public interface IProfessorRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a professor by ID without related entities.
|
||||
/// </summary>
|
||||
Task<Professor?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all professors without related entities.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all professors with their subjects.
|
||||
/// </summary>
|
||||
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>(
|
||||
Expression<Func<Professor, TResult>> selector,
|
||||
CancellationToken ct = default);
|
||||
|
|
|
|||
|
|
@ -3,32 +3,85 @@ namespace Domain.Ports.Repositories;
|
|||
using Domain.Entities;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for student persistence operations.
|
||||
/// </summary>
|
||||
public interface IStudentRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a student by ID without related entities.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all students without related entities.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all students with enrollments and related subjects.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Student>> GetAllWithEnrollmentsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a student exists by ID.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
// Projection support for optimized queries
|
||||
/// <summary>
|
||||
/// Gets all students with custom projection to avoid over-fetching.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||
Expression<Func<Student, TResult>> selector,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a student by ID with custom projection.
|
||||
/// </summary>
|
||||
Task<TResult?> GetByIdProjectedAsync<TResult>(
|
||||
int id,
|
||||
Expression<Func<Student, TResult>> selector,
|
||||
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>(
|
||||
Expression<Func<Student, TResult>> selector,
|
||||
int? afterId = null,
|
||||
int pageSize = 10,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new student to the context.
|
||||
/// </summary>
|
||||
void Add(Student student);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a student as modified in the context.
|
||||
/// </summary>
|
||||
void Update(Student student);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a student from the context.
|
||||
/// </summary>
|
||||
void Delete(Student student);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,39 @@ namespace Domain.Ports.Repositories;
|
|||
using Domain.Entities;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for subject persistence operations.
|
||||
/// </summary>
|
||||
public interface ISubjectRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a subject by ID without related entities.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all subjects without related entities.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all subjects with their professors.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
// Projection support
|
||||
/// <summary>
|
||||
/// Gets all subjects with custom projection to avoid over-fetching.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||
Expression<Func<Subject, TResult>> selector,
|
||||
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.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Domain service for enrollment business rules.
|
||||
/// Contains validation logic that spans multiple aggregates.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
if (!student.CanEnroll())
|
||||
|
|
@ -17,6 +29,12 @@ public class EnrollmentDomainService
|
|||
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)
|
||||
{
|
||||
ValidateEnrollment(student, subject);
|
||||
|
|
@ -26,4 +44,30 @@ public class EnrollmentDomainService
|
|||
|
||||
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>
|
||||
<ProjectReference Include="..\..\src\backend\Application\Application.csproj" />
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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<CancellationToken>())
|
||||
.Returns(student);
|
||||
|
||||
var classmate1 = new Student("Jane Doe", Email.Create("jane@test.com"));
|
||||
var classmate2 = new Student("Bob Smith", Email.Create("bob@test.com"));
|
||||
SetEntityId(classmate1, 2);
|
||||
SetEntityId(classmate2, 3);
|
||||
var classmate1 = new ClassmateInfo(2, "Jane Doe");
|
||||
var classmate2 = new ClassmateInfo(3, "Bob Smith");
|
||||
|
||||
var batchResult = new Dictionary<int, IReadOnlyList<Student>>
|
||||
var batchResult = new Dictionary<int, IReadOnlyList<ClassmateInfo>>
|
||||
{
|
||||
[1] = [classmate1],
|
||||
[2] = [classmate2]
|
||||
|
|
@ -129,7 +128,7 @@ public class GetClassmatesQueryTests
|
|||
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
|
||||
.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>())
|
||||
.Returns(emptyBatchResult);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IStudentRepository>();
|
||||
_subjectRepository = Substitute.For<ISubjectRepository>();
|
||||
_handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository);
|
||||
_enrollmentDomainService = new Domain.Services.EnrollmentDomainService();
|
||||
_handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository, _enrollmentDomainService);
|
||||
}
|
||||
|
||||
[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>
|
||||
<ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Reference in New Issue