已完成“选课候补与自动递补”完整流程。
学生端:满员后可加入/取消候补,展示候补顺位和当前候补人数。 自动递补:退课释放名额后,按候补时间顺序重新校验学籍、范围、学分及时间冲突;不合格者自动失效并继续下一位。 管理端:教学班显示候补人数,名单抽屉提供候补队列及移出操作。 状态通知:递补成功、资格失效、批次关闭、管理员移出均会通知学生。 并发安全:退课和递补放在 Serializable 事务中执行。 数据库:已补充 SQLite 开发迁移和 MySQL 正式迁移。
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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<AppDbContext>()
|
||||
.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<AcceptedResult>(firstResult);
|
||||
|
||||
var secondController = new CourseSelectionsController(
|
||||
db,
|
||||
new StudentDataScope(data.SecondWaiterUserId));
|
||||
var secondResult = await secondController.JoinWaitlist(
|
||||
new StudentEnrollmentRequest(data.OfferingId),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<AcceptedResult>(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<OkObjectResult>(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<AppDbContext>()
|
||||
.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<AcceptedResult>(await studentController.JoinWaitlist(
|
||||
new StudentEnrollmentRequest(data.OfferingId),
|
||||
CancellationToken.None));
|
||||
|
||||
var adminController = new CourseSelectionsController(
|
||||
db,
|
||||
new AdminDataScope());
|
||||
Assert.IsType<OkObjectResult>(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<SeededSelection> 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<string>([SystemRoles.Student]));
|
||||
}
|
||||
|
||||
private sealed class AdminDataScope : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
Guid.NewGuid(),
|
||||
"测试管理员",
|
||||
null,
|
||||
DataScope.All,
|
||||
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||
}
|
||||
|
||||
private sealed record SeededSelection(
|
||||
Guid RoundId,
|
||||
Guid OfferingId,
|
||||
Guid EnrollmentId,
|
||||
Guid EnrolledStudentId,
|
||||
Guid FirstWaiterStudentId,
|
||||
Guid SecondWaiterStudentId,
|
||||
Guid EnrolledUserId,
|
||||
Guid FirstWaiterUserId,
|
||||
Guid SecondWaiterUserId);
|
||||
}
|
||||
Reference in New Issue
Block a user