Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs
T
biss f62cd21319 已把选课的“年级划分”和限制规则补成完整闭环:
选课批次可指定多个适用年级;留空兼容原有“全部年级”。
新增可选的“最多课程门数”,与原有学分上限并行。
年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。
普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。
管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。
新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
2026-07-26 16:12:21 +08:00

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);
}