Files
2026-07-27 16:06:02 +08:00

501 lines
18 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.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Routing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class NotificationsControllerTests
{
[Fact]
public void Send_message_request_validation_metadata_is_compatible_with_mvc_record_binding()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddControllers()
.AddApplicationPart(typeof(NotificationsController).Assembly);
using var serviceProvider = services.BuildServiceProvider();
var objectValidator = serviceProvider.GetRequiredService<IObjectModelValidator>();
var httpContext = new DefaultHttpContext
{
RequestServices = serviceProvider
};
var actionContext = new ActionContext(
httpContext,
new RouteData(),
new ActionDescriptor(),
new ModelStateDictionary());
var request = new SendMessageRequest(
"校内通知",
"<p>这是一条用于验证请求模型绑定的通知。</p>");
var exception = Record.Exception(() =>
objectValidator.Validate(actionContext, null, string.Empty, request));
Assert.Null(exception);
Assert.True(actionContext.ModelState.IsValid);
}
[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 School_administrator_can_filter_recipients_by_college()
{
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(
"第二学院通知",
"<p><strong>仅发送</strong>给第二学院。</p>",
RecipientMode: MessageRecipientMode.Filtered,
RecipientFilter: new MessageRecipientFilter(
CollegeId: fixture.SecondCollege.Id)),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notification = Assert.Single(
await fixture.Db.Notifications.ToListAsync());
Assert.Equal(fixture.OutsideRecipient.Id, notification.UserId);
var dispatch = await fixture.Db.MessageDispatches.SingleAsync();
Assert.Equal(MessageAudienceType.Custom, dispatch.AudienceType);
Assert.Equal("指定学院", dispatch.AudienceName);
}
[Fact]
public async Task College_administrator_cannot_select_recipient_outside_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(
"越权消息",
"<p>不应发送。</p>",
RecipientMode: MessageRecipientMode.Selected,
RecipientUserIds: [fixture.OutsideRecipient.Id]),
CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
Assert.Empty(await fixture.Db.Notifications.ToListAsync());
Assert.Empty(await fixture.Db.MessageDispatches.ToListAsync());
}
[Fact]
public async Task Administrator_can_send_rich_content_larger_than_legacy_limit()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var content = $"<h2>教学安排</h2><p>{new string('内', 1500)}</p>";
var result = await controller.Send(
new SendMessageRequest(
"富文本通知",
content,
RecipientMode: MessageRecipientMode.Selected,
RecipientUserIds: [fixture.ClassStudentUser.Id]),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notification = await fixture.Db.Notifications.SingleAsync();
Assert.Equal(content, notification.Content);
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
}
[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,
College secondCollege,
ApplicationUser sender,
ApplicationUser collegeRecipient,
ApplicationUser outsideRecipient,
ApplicationUser disabledRecipient,
ApplicationUser teacherUser,
ApplicationUser classStudentUser,
TeachingTask ownTask,
TeachingTask otherTask)
{
this.connection = connection;
Db = db;
FirstCollege = firstCollege;
SecondCollege = secondCollege;
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 College SecondCollege { 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,
secondCollege,
sender,
collegeRecipient,
outsideRecipient,
disabledRecipient,
teacherUser,
classStudentUser,
ownTask,
otherTask);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
}
}