选课批次可指定多个适用年级;留空兼容原有“全部年级”。 新增可选的“最多课程门数”,与原有学分上限并行。 年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。 普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。 管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。 新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
387 lines
13 KiB
C#
387 lines
13 KiB
C#
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 == "课程候补已结束"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Student_cannot_join_waitlist_outside_the_round_grade_scope()
|
|
{
|
|
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);
|
|
|
|
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
|
|
{
|
|
CourseSelectionRoundId = data.RoundId,
|
|
Grade = 2025
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = new CourseSelectionsController(
|
|
db,
|
|
new StudentDataScope(data.FirstWaiterUserId));
|
|
var result = await controller.JoinWaitlist(
|
|
new StudentEnrollmentRequest(data.OfferingId),
|
|
CancellationToken.None);
|
|
|
|
var conflict = Assert.IsType<ConflictObjectResult>(result);
|
|
var problem = Assert.IsType<ProblemDetails>(conflict.Value);
|
|
Assert.Equal("你所在的年级不属于本轮选课对象。", problem.Detail);
|
|
Assert.False(await db.CourseEnrollments.AnyAsync(x =>
|
|
x.StudentId == data.FirstWaiterStudentId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Updating_round_preserves_an_unchanged_grade_without_duplicate_rows()
|
|
{
|
|
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 round = await db.CourseSelectionRounds.FindAsync(data.RoundId);
|
|
round!.Status = CourseSelectionRoundStatus.Draft;
|
|
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
|
|
{
|
|
CourseSelectionRoundId = data.RoundId,
|
|
Grade = 2026
|
|
});
|
|
await db.SaveChangesAsync();
|
|
db.ChangeTracker.Clear();
|
|
|
|
var request = new CourseSelectionRoundRequest(
|
|
round.AcademicTermId,
|
|
"2026 级第一轮选课",
|
|
DateTime.UtcNow.AddHours(-1),
|
|
DateTime.UtcNow.AddDays(7),
|
|
DateTime.UtcNow.AddDays(14),
|
|
30,
|
|
6,
|
|
[2026],
|
|
null);
|
|
var controller = new CourseSelectionsController(db, new AdminDataScope());
|
|
|
|
Assert.IsType<NoContentResult>(await controller.UpdateRound(
|
|
data.RoundId,
|
|
request,
|
|
CancellationToken.None));
|
|
db.ChangeTracker.Clear();
|
|
Assert.IsType<NoContentResult>(await controller.UpdateRound(
|
|
data.RoundId,
|
|
request,
|
|
CancellationToken.None));
|
|
Assert.Equal(
|
|
1,
|
|
await db.CourseSelectionRoundGrades.CountAsync(x =>
|
|
x.CourseSelectionRoundId == data.RoundId &&
|
|
x.Grade == 2026));
|
|
}
|
|
|
|
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);
|
|
}
|