501 lines
18 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|