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(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(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(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(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(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 CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .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(); } } }