成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。 选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。 关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
@@ -95,18 +95,31 @@ public sealed class CourseSelectionsControllerTests
|
||||
var adminController = new CourseSelectionsController(
|
||||
db,
|
||||
new AdminDataScope());
|
||||
Assert.IsType<OkObjectResult>(await adminController.CloseRound(
|
||||
var closeResult = Assert.IsType<OkObjectResult>(await adminController.CloseRound(
|
||||
data.RoundId,
|
||||
CancellationToken.None));
|
||||
Assert.Equal(2, ReadIntProperty(closeResult.Value, "NotifiedStudentCount"));
|
||||
|
||||
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 == "课程候补已结束"));
|
||||
var resultNotifications = await db.Notifications
|
||||
.Where(x => x.Title == "第一轮选课结果")
|
||||
.OrderBy(x => x.UserId)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, resultNotifications.Count);
|
||||
var selectedNotification = Assert.Single(resultNotifications
|
||||
.Where(x => x.UserId == data.EnrolledUserId));
|
||||
Assert.Contains("最终选中 1 门", selectedNotification.Content);
|
||||
Assert.Contains("《程序设计基础》", selectedNotification.Content);
|
||||
var waitlistNotification = Assert.Single(resultNotifications
|
||||
.Where(x => x.UserId == data.FirstWaiterUserId));
|
||||
Assert.Contains("本轮未选中课程", waitlistNotification.Content);
|
||||
Assert.Contains("候补未成功 1 门", waitlistNotification.Content);
|
||||
Assert.Equal(NotificationCategory.CourseSelection, waitlistNotification.Category);
|
||||
Assert.Equal("/course-selections", waitlistNotification.LinkUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -337,6 +350,14 @@ public sealed class CourseSelectionsControllerTests
|
||||
DisplayName = displayName
|
||||
};
|
||||
|
||||
private static int ReadIntProperty(object? value, string propertyName)
|
||||
{
|
||||
Assert.NotNull(value);
|
||||
var property = value.GetType().GetProperty(propertyName);
|
||||
Assert.NotNull(property);
|
||||
return Assert.IsType<int>(property.GetValue(value));
|
||||
}
|
||||
|
||||
private static Student CreateStudent(
|
||||
string number,
|
||||
string name,
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
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 NotificationsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task College_administrator_can_only_send_to_enabled_users_in_own_college()
|
||||
{
|
||||
await using var fixture = await NotificationFixture.CreateAsync();
|
||||
var controller = new NotificationsController(
|
||||
fixture.Db,
|
||||
new TestDataScope(
|
||||
fixture.Sender.Id,
|
||||
fixture.FirstCollege.Id,
|
||||
SystemRoles.CollegeAdmin));
|
||||
|
||||
var result = await controller.Send(
|
||||
new SendMessageRequest(
|
||||
"学院通知",
|
||||
"请按时完成教学材料提交。"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var notifications = await fixture.Db.Notifications
|
||||
.Include(x => x.MessageDispatch)
|
||||
.ToListAsync();
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
fixture.ClassStudentUser.Id,
|
||||
fixture.CollegeRecipient.Id,
|
||||
fixture.TeacherUser.Id
|
||||
}.Order(),
|
||||
notifications.Select(x => x.UserId).Order());
|
||||
var dispatch = Assert.Single(
|
||||
notifications.Select(x => x.MessageDispatch).Distinct());
|
||||
Assert.Equal("第一学院全院成员", dispatch!.AudienceName);
|
||||
Assert.Equal(3, dispatch.RecipientCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Teacher_can_send_notice_only_to_own_teaching_class()
|
||||
{
|
||||
await using var fixture = await NotificationFixture.CreateAsync();
|
||||
var controller = new NotificationsController(
|
||||
fixture.Db,
|
||||
new TestDataScope(
|
||||
fixture.TeacherUser.Id,
|
||||
fixture.FirstCollege.Id,
|
||||
SystemRoles.Teacher));
|
||||
|
||||
var result = await controller.Send(
|
||||
new SendMessageRequest(
|
||||
"课程资料提醒",
|
||||
"请在规定时间内完成课程资料提交。",
|
||||
fixture.OwnTask.Id),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var notification = Assert.Single(await fixture.Db.Notifications
|
||||
.Include(x => x.MessageDispatch)
|
||||
.ToListAsync());
|
||||
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
|
||||
Assert.Equal(NotificationCategory.General, notification.Category);
|
||||
Assert.Null(notification.LinkUrl);
|
||||
Assert.Equal(
|
||||
MessageAudienceType.TeachingTask,
|
||||
notification.MessageDispatch!.AudienceType);
|
||||
|
||||
fixture.Db.Notifications.Remove(notification);
|
||||
fixture.Db.MessageDispatches.Remove(notification.MessageDispatch);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var forbiddenTask = await controller.Send(
|
||||
new SendMessageRequest(
|
||||
"越权消息",
|
||||
"不应发送。",
|
||||
fixture.OtherTask.Id),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(forbiddenTask);
|
||||
Assert.Empty(await fixture.Db.Notifications.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task School_administrator_reaches_all_enabled_accounts_except_self()
|
||||
{
|
||||
await using var fixture = await NotificationFixture.CreateAsync();
|
||||
var controller = new NotificationsController(
|
||||
fixture.Db,
|
||||
new TestDataScope(
|
||||
fixture.Sender.Id,
|
||||
fixture.FirstCollege.Id,
|
||||
SystemRoles.AcademicAdmin));
|
||||
|
||||
var result = await controller.Send(
|
||||
new SendMessageRequest(
|
||||
"全校通知",
|
||||
"本周五进行系统维护。"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var recipientIds = await fixture.Db.Notifications
|
||||
.Select(x => x.UserId)
|
||||
.ToListAsync();
|
||||
Assert.DoesNotContain(fixture.Sender.Id, recipientIds);
|
||||
Assert.DoesNotContain(fixture.DisabledRecipient.Id, recipientIds);
|
||||
Assert.Contains(fixture.CollegeRecipient.Id, recipientIds);
|
||||
Assert.Contains(fixture.OutsideRecipient.Id, recipientIds);
|
||||
Assert.Contains(fixture.TeacherUser.Id, recipientIds);
|
||||
Assert.Contains(fixture.ClassStudentUser.Id, recipientIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publishing_course_grades_automatically_notifies_roster_students()
|
||||
{
|
||||
await using var fixture = await NotificationFixture.CreateAsync();
|
||||
var studentId = await fixture.Db.Students
|
||||
.Where(x => x.UserId == fixture.ClassStudentUser.Id)
|
||||
.Select(x => x.Id)
|
||||
.SingleAsync();
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = fixture.OwnTask.Id,
|
||||
Status = GradeSheetStatus.Approved,
|
||||
Records =
|
||||
[
|
||||
new GradeRecord
|
||||
{
|
||||
StudentId = studentId,
|
||||
RegularScore = 88,
|
||||
FinalScore = 92,
|
||||
TotalScore = 90.8m,
|
||||
GradePoint = 4
|
||||
}
|
||||
]
|
||||
};
|
||||
fixture.Db.GradeSheets.Add(sheet);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
fixture.Db.ChangeTracker.Clear();
|
||||
|
||||
var controller = new GradesController(
|
||||
fixture.Db,
|
||||
new TestDataScope(
|
||||
fixture.Sender.Id,
|
||||
fixture.FirstCollege.Id,
|
||||
SystemRoles.AcademicAdmin));
|
||||
|
||||
Assert.IsType<NoContentResult>(await controller.Publish(
|
||||
sheet.Id,
|
||||
CancellationToken.None));
|
||||
|
||||
var notification = Assert.Single(await fixture.Db.Notifications
|
||||
.Where(x => x.UserId == fixture.ClassStudentUser.Id)
|
||||
.ToListAsync());
|
||||
Assert.Equal("成绩已发布", notification.Title);
|
||||
Assert.Contains("《测试课程》", notification.Content);
|
||||
Assert.DoesNotContain("90.8", notification.Content);
|
||||
Assert.Equal(NotificationCategory.Grade, notification.Category);
|
||||
Assert.Equal("/grades", notification.LinkUrl);
|
||||
}
|
||||
|
||||
private sealed class TestDataScope(
|
||||
Guid userId,
|
||||
Guid? collegeId,
|
||||
params string[] roles) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId,
|
||||
"测试发送人",
|
||||
collegeId,
|
||||
EffectiveDataScopeResolver.Resolve(roles),
|
||||
roles.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private sealed class NotificationFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection connection;
|
||||
|
||||
private NotificationFixture(
|
||||
SqliteConnection connection,
|
||||
AppDbContext db,
|
||||
College firstCollege,
|
||||
ApplicationUser sender,
|
||||
ApplicationUser collegeRecipient,
|
||||
ApplicationUser outsideRecipient,
|
||||
ApplicationUser disabledRecipient,
|
||||
ApplicationUser teacherUser,
|
||||
ApplicationUser classStudentUser,
|
||||
TeachingTask ownTask,
|
||||
TeachingTask otherTask)
|
||||
{
|
||||
this.connection = connection;
|
||||
Db = db;
|
||||
FirstCollege = firstCollege;
|
||||
Sender = sender;
|
||||
CollegeRecipient = collegeRecipient;
|
||||
OutsideRecipient = outsideRecipient;
|
||||
DisabledRecipient = disabledRecipient;
|
||||
TeacherUser = teacherUser;
|
||||
ClassStudentUser = classStudentUser;
|
||||
OwnTask = ownTask;
|
||||
OtherTask = otherTask;
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
public College FirstCollege { get; }
|
||||
public ApplicationUser Sender { get; }
|
||||
public ApplicationUser CollegeRecipient { get; }
|
||||
public ApplicationUser OutsideRecipient { get; }
|
||||
public ApplicationUser DisabledRecipient { get; }
|
||||
public ApplicationUser TeacherUser { get; }
|
||||
public ApplicationUser ClassStudentUser { get; }
|
||||
public TeachingTask OwnTask { get; }
|
||||
public TeachingTask OtherTask { get; }
|
||||
|
||||
public static async Task<NotificationFixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var firstCollege = new College
|
||||
{
|
||||
Code = "C01",
|
||||
Name = "第一学院"
|
||||
};
|
||||
var secondCollege = new College
|
||||
{
|
||||
Code = "C02",
|
||||
Name = "第二学院"
|
||||
};
|
||||
var users = Enumerable.Range(1, 6)
|
||||
.Select(index => new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = $"user{index}",
|
||||
NormalizedUserName = $"USER{index}",
|
||||
DisplayName = $"用户{index}",
|
||||
CollegeId = index == 3 ? secondCollege.Id : firstCollege.Id,
|
||||
IsEnabled = index != 4
|
||||
})
|
||||
.ToArray();
|
||||
var sender = users[0];
|
||||
var collegeRecipient = users[1];
|
||||
var outsideRecipient = users[2];
|
||||
var disabledRecipient = users[3];
|
||||
var teacherUser = users[4];
|
||||
var classStudentUser = users[5];
|
||||
|
||||
var major = new Major
|
||||
{
|
||||
Code = "M01",
|
||||
Name = "测试专业",
|
||||
CollegeId = firstCollege.Id,
|
||||
DegreeType = "本科",
|
||||
SchoolingYears = 4
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "CL01",
|
||||
Name = "测试行政班",
|
||||
MajorId = major.Id,
|
||||
Grade = 2025
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "20250001",
|
||||
Name = "测试学生",
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2025,
|
||||
EnrollmentDate = new DateOnly(2025, 9, 1),
|
||||
UserId = classStudentUser.Id
|
||||
};
|
||||
var teacher = new Teacher
|
||||
{
|
||||
TeacherNumber = "T001",
|
||||
Name = "测试教师",
|
||||
CollegeId = firstCollege.Id,
|
||||
UserId = teacherUser.Id
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "2025-A",
|
||||
Name = "2025 秋季学期",
|
||||
AcademicYear = "2025-2026",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2025, 9, 1),
|
||||
EndDate = new DateOnly(2026, 1, 20),
|
||||
IsCurrent = true
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "COURSE-1",
|
||||
Name = "测试课程",
|
||||
CollegeId = firstCollege.Id,
|
||||
Credits = 2,
|
||||
TotalHours = 32,
|
||||
LectureHours = 32,
|
||||
PracticeHours = 0,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
var ownTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "TASK-1",
|
||||
Name = "测试课程教学班",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 30,
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
ownTask.Teachers.Add(new TeachingTaskTeacher
|
||||
{
|
||||
TeachingTaskId = ownTask.Id,
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
});
|
||||
ownTask.Classes.Add(new TeachingTaskClass
|
||||
{
|
||||
TeachingTaskId = ownTask.Id,
|
||||
AdministrativeClassId = administrativeClass.Id
|
||||
});
|
||||
var otherTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "TASK-2",
|
||||
Name = "其他教学班",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 30,
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
|
||||
db.AddRange(firstCollege, secondCollege, term);
|
||||
db.Users.AddRange(users);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(major);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(course);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(teacher);
|
||||
await db.SaveChangesAsync();
|
||||
db.Add(administrativeClass);
|
||||
await db.SaveChangesAsync();
|
||||
db.AddRange(student, ownTask, otherTask);
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
return new NotificationFixture(
|
||||
connection,
|
||||
db,
|
||||
firstCollege,
|
||||
sender,
|
||||
collegeRecipient,
|
||||
outsideRecipient,
|
||||
disabledRecipient,
|
||||
teacherUser,
|
||||
classStudentUser,
|
||||
ownTask,
|
||||
otherTask);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user