using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Tests; public sealed class CourseSelectionsControllerTests { [Fact] public async Task Withdrawal_skips_invalid_waiter_and_promotes_next_student() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); var data = await SeedFullOfferingAsync(db); var firstController = new CourseSelectionsController( db, new StudentDataScope(data.FirstWaiterUserId)); var firstResult = await firstController.JoinWaitlist( new StudentEnrollmentRequest(data.OfferingId), CancellationToken.None); Assert.IsType(firstResult); var secondController = new CourseSelectionsController( db, new StudentDataScope(data.SecondWaiterUserId)); var secondResult = await secondController.JoinWaitlist( new StudentEnrollmentRequest(data.OfferingId), CancellationToken.None); Assert.IsType(secondResult); db.ChangeTracker.Clear(); var firstWaiter = await db.Students.SingleAsync( x => x.UserId == data.FirstWaiterUserId); firstWaiter.Status = StudentStatus.Suspended; await db.SaveChangesAsync(); var enrolledController = new CourseSelectionsController( db, new StudentDataScope(data.EnrolledUserId)); var withdrawResult = await enrolledController.Withdraw( data.EnrollmentId, CancellationToken.None); Assert.IsType(withdrawResult); db.ChangeTracker.Clear(); var enrollments = await db.CourseEnrollments .OrderBy(x => x.StudentId) .ToListAsync(); Assert.Equal( CourseEnrollmentStatus.Withdrawn, enrollments.Single(x => x.StudentId == data.EnrolledStudentId).Status); Assert.Equal( CourseEnrollmentStatus.Expired, enrollments.Single(x => x.StudentId == data.FirstWaiterStudentId).Status); Assert.Equal( CourseEnrollmentStatus.Enrolled, enrollments.Single(x => x.StudentId == data.SecondWaiterStudentId).Status); Assert.Equal( 2, await db.Notifications.CountAsync(x => x.Title == "课程候补已失效" || x.Title == "课程候补递补成功")); } [Fact] public async Task Closing_round_expires_remaining_waitlist_and_notifies_student() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); var data = await SeedFullOfferingAsync(db); var studentController = new CourseSelectionsController( db, new StudentDataScope(data.FirstWaiterUserId)); Assert.IsType(await studentController.JoinWaitlist( new StudentEnrollmentRequest(data.OfferingId), CancellationToken.None)); var adminController = new CourseSelectionsController( db, new AdminDataScope()); Assert.IsType(await adminController.CloseRound( data.RoundId, CancellationToken.None)); db.ChangeTracker.Clear(); var waitlist = await db.CourseEnrollments.SingleAsync( x => x.StudentId == data.FirstWaiterStudentId); Assert.Equal(CourseEnrollmentStatus.Expired, waitlist.Status); Assert.NotNull(waitlist.WithdrawnAt); Assert.True(await db.Notifications.AnyAsync(x => x.UserId == data.FirstWaiterUserId && x.Title == "课程候补已结束")); } private static async Task SeedFullOfferingAsync(AppDbContext db) { var college = new College { Code = "CS", Name = "计算机学院" }; var major = new Major { Code = "080901", Name = "计算机科学与技术", CollegeId = college.Id, DegreeType = "工学学士" }; var administrativeClass = new AdministrativeClass { Code = "CS2026-01", Name = "计科 2026-1 班", MajorId = major.Id, Grade = 2026 }; var term = new AcademicTerm { Code = "2026-1", Name = "2026—2027 学年第一学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 20) }; var course = new Course { Code = "CS101", Name = "程序设计基础", CollegeId = college.Id, Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16, Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination }; var task = new TeachingTask { TaskNumber = "2026-1-CS101-01", Name = "程序设计基础教学班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 1, SchedulingMode = TeachingTaskSchedulingMode.Flexible, Status = TeachingTaskStatus.Published, Classes = [ new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id } ] }; var round = new CourseSelectionRound { AcademicTermId = term.Id, Name = "第一轮选课", StartsAt = DateTime.UtcNow.AddHours(-1), EndsAt = DateTime.UtcNow.AddHours(1), WithdrawalEndsAt = DateTime.UtcNow.AddHours(2), MaxCredits = 30, Status = CourseSelectionRoundStatus.Open }; var offering = new CourseSelectionOffering { CourseSelectionRoundId = round.Id, TeachingTaskId = task.Id, Capacity = 1 }; var enrolledUser = CreateUser("2026001", "已选学生"); var firstWaiterUser = CreateUser("2026002", "候补甲"); var secondWaiterUser = CreateUser("2026003", "候补乙"); var enrolledStudent = CreateStudent( "2026001", "已选学生", enrolledUser.Id, administrativeClass.Id); var firstWaiter = CreateStudent( "2026002", "候补甲", firstWaiterUser.Id, administrativeClass.Id); var secondWaiter = CreateStudent( "2026003", "候补乙", secondWaiterUser.Id, administrativeClass.Id); var enrollment = new CourseEnrollment { CourseSelectionOfferingId = offering.Id, StudentId = enrolledStudent.Id }; db.Add(college); await db.SaveChangesAsync(); db.AddRange( major, term, course, enrolledUser, firstWaiterUser, secondWaiterUser); await db.SaveChangesAsync(); db.Add(administrativeClass); await db.SaveChangesAsync(); db.AddRange( enrolledStudent, firstWaiter, secondWaiter); await db.SaveChangesAsync(); db.AddRange(task, round); await db.SaveChangesAsync(); db.Add(offering); await db.SaveChangesAsync(); db.Add(enrollment); await db.SaveChangesAsync(); return new SeededSelection( round.Id, offering.Id, enrollment.Id, enrolledStudent.Id, firstWaiter.Id, secondWaiter.Id, enrolledUser.Id, firstWaiterUser.Id, secondWaiterUser.Id); } private static ApplicationUser CreateUser(string userName, string displayName) => new() { Id = Guid.NewGuid(), UserName = userName, NormalizedUserName = userName, DisplayName = displayName }; private static Student CreateStudent( string number, string name, Guid userId, Guid classId) => new() { StudentNumber = number, Name = name, UserId = userId, AdministrativeClassId = classId, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1), Status = StudentStatus.Active }; private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( userId, "测试学生", null, DataScope.Self, new HashSet([SystemRoles.Student])); } private sealed class AdminDataScope : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( Guid.NewGuid(), "测试管理员", null, DataScope.All, new HashSet([SystemRoles.SuperAdmin])); } private sealed record SeededSelection( Guid RoundId, Guid OfferingId, Guid EnrollmentId, Guid EnrolledStudentId, Guid FirstWaiterStudentId, Guid SecondWaiterStudentId, Guid EnrolledUserId, Guid FirstWaiterUserId, Guid SecondWaiterUserId); }