using System.Text; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Jiaowu.Api.Tests; public sealed class PersonalCalendarServiceTests : IAsyncLifetime { private readonly SqliteConnection _connection = new("Data Source=:memory:"); private AppDbContext _db = null!; public async Task InitializeAsync() { await _connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(_connection) .Options; _db = new AppDbContext(options); await _db.Database.EnsureCreatedAsync(); } public async Task DisposeAsync() { await _db.DisposeAsync(); await _connection.DisposeAsync(); } [Fact] public async Task Build_creates_live_teacher_feed_with_courses_and_exams() { var today = DateOnly.FromDateTime(DateTime.Today); var mondayOffset = today.DayOfWeek == DayOfWeek.Sunday ? 6 : (int)today.DayOfWeek - 1; var termStart = today.AddDays(-mondayOffset); var campus = new Campus { Code = "MAIN", Name = "主校区" }; var college = new College { Code = "CS", Name = "计算机学院", CampusId = campus.Id }; var user = new ApplicationUser { Id = Guid.NewGuid(), UserName = "calendar-teacher", NormalizedUserName = "CALENDAR-TEACHER", DisplayName = "陈老师", CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(), CalendarSubscriptionCreatedAt = DateTime.UtcNow }; var teacher = new Teacher { TeacherNumber = "T-CALENDAR", Name = "陈老师", CollegeId = college.Id, UserId = user.Id, Status = TeacherStatus.Active }; var course = new Course { Code = "CS101-CALENDAR", Name = "程序设计基础", CollegeId = college.Id, Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16, Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination }; var term = new AcademicTerm { Code = "CALENDAR-TERM", Name = "个人日历测试学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = termStart, EndDate = termStart.AddDays(40), IsCurrent = true }; var building = new Building { Code = "J-CALENDAR", Name = "第一教学楼", CampusId = campus.Id }; var classroom = new Classroom { Code = "J-CALENDAR-101", Name = "101", BuildingId = building.Id, Capacity = 60 }; _db.AddRange( campus, college, user, teacher, course, term, building, classroom); await _db.SaveChangesAsync(); var scheduledTask = new TeachingTask { TaskNumber = "CALENDAR-TASK-01", Name = "程序设计基础教学班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, Status = TeachingTaskStatus.Published, Teachers = [ new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true } ] }; var flexibleTask = new TeachingTask { TaskNumber = "CALENDAR-TASK-02", Name = "程序设计实践", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, Status = TeachingTaskStatus.Published, SchedulingMode = TeachingTaskSchedulingMode.Flexible, StartWeek = 1, EndWeek = 4, WeeklyHours = 2, Teachers = [ new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true } ] }; var plan = new SchedulePlan { AcademicTermId = term.Id, Name = "正式课表", Version = "V1", Status = SchedulePlanStatus.Published, PublishedAt = DateTime.UtcNow }; _db.AddRange( scheduledTask, flexibleTask, plan, new ScheduleTimeSlot { AcademicTermId = term.Id, PeriodNumber = 1, Name = "第一节", StartsAt = new TimeOnly(8, 0), EndsAt = new TimeOnly(8, 45) }, new ScheduleTimeSlot { AcademicTermId = term.Id, PeriodNumber = 2, Name = "第二节", StartsAt = new TimeOnly(8, 55), EndsAt = new TimeOnly(9, 40) }); await _db.SaveChangesAsync(); _db.ScheduleEntries.Add(new ScheduleEntry { SchedulePlanId = plan.Id, TeachingTaskId = scheduledTask.Id, ClassroomId = classroom.Id, DayOfWeek = 1, StartPeriod = 1, PeriodCount = 2, StartWeek = 1, EndWeek = 2, WeekPattern = WeekPattern.All }); var examDate = termStart.AddDays(5); var examPlan = new ExamPlan { AcademicTermId = term.Id, Name = "期末考试", Status = ExamPlanStatus.Published, PublishedAt = DateTime.UtcNow }; var exam = new ExamSession { ExamPlan = examPlan, TeachingTaskId = scheduledTask.Id, ClassroomId = classroom.Id, ExamDate = examDate, StartPeriod = 1, PeriodCount = 2, StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)), EndsAt = examDate.ToDateTime(new TimeOnly(9, 40)), Invigilators = [ new ExamSessionInvigilator { TeacherId = teacher.Id } ] }; var makeupPlan = new MakeupExamPlan { AcademicTermId = term.Id, Name = "补考安排", Status = MakeupExamPlanStatus.Published, PublishedAt = DateTime.UtcNow }; var makeupDate = termStart.AddDays(12); var makeup = new MakeupExamSession { MakeupExamPlan = makeupPlan, TeachingTaskId = scheduledTask.Id, ClassroomId = classroom.Id, ExamDate = makeupDate, StartPeriod = 1, PeriodCount = 2, StartsAt = makeupDate.ToDateTime(new TimeOnly(8, 0)), EndsAt = makeupDate.ToDateTime(new TimeOnly(9, 40)), Invigilators = [ new MakeupExamSessionInvigilator { TeacherId = teacher.Id } ] }; _db.AddRange(exam, makeup); await _db.SaveChangesAsync(); var service = CreateService(); var result = await service.BuildAsync(user, CancellationToken.None); Assert.NotNull(result); Assert.Equal(5, result.EventCount); var ics = Encoding.UTF8.GetString(result.Content); Assert.Contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H", ics); Assert.Contains("SUMMARY:程序设计基础", ics); Assert.Contains("SUMMARY:[监考] 程序设计基础", ics); Assert.Contains("SUMMARY:[补考监考] 程序设计基础", ics); Assert.Contains("SUMMARY:[灵活安排] 程序设计基础", ics); Assert.Contains( $"DTSTART:{termStart:yyyyMMdd}T000000Z", ics); Assert.All( ics.Split("\r\n", StringSplitOptions.RemoveEmptyEntries), line => Assert.True( Encoding.UTF8.GetByteCount(line) <= 75, $"ICS line exceeds 75 octets: {line}")); } [Fact] public async Task Build_creates_student_feed_with_booked_experiment() { var today = DateOnly.FromDateTime(DateTime.Today); var mondayOffset = today.DayOfWeek == DayOfWeek.Sunday ? 6 : (int)today.DayOfWeek - 1; var termStart = today.AddDays(-mondayOffset); var user = new ApplicationUser { Id = Guid.NewGuid(), UserName = "calendar-student", NormalizedUserName = "CALENDAR-STUDENT", DisplayName = "实验学生", CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(), CalendarSubscriptionCreatedAt = DateTime.UtcNow }; var campus = new Campus { Code = "LAB-MAIN", Name = "主校区" }; var college = new College { Code = "LAB-CS", Name = "计算机学院", CampusId = campus.Id }; var major = new Major { Code = "LAB-SE", Name = "软件工程", CollegeId = college.Id, DegreeType = "工学" }; var administrativeClass = new AdministrativeClass { Code = "LAB-CLASS", Name = "软件工程实验班", MajorId = major.Id, Grade = today.Year }; var student = new Student { StudentNumber = "LAB-STUDENT-01", Name = "实验学生", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = today.Year, EnrollmentDate = termStart, UserId = user.Id, Status = StudentStatus.Active }; var teacher = new Teacher { TeacherNumber = "LAB-TEACHER-01", Name = "实验教师", CollegeId = college.Id, Status = TeacherStatus.Active }; var course = new Course { Code = "LAB-CALENDAR", Name = "嵌入式系统实验", CollegeId = college.Id, Credits = 2, TotalHours = 32, LectureHours = 16, PracticeHours = 16, Nature = CourseNature.Practice, AssessmentMethod = AssessmentMethod.Assessment }; var term = new AcademicTerm { Code = "LAB-CALENDAR-TERM", Name = "实验日历测试学期", AcademicYear = $"{today.Year}-{today.Year + 1}", Season = TermSeason.Autumn, StartDate = termStart, EndDate = termStart.AddDays(40), IsCurrent = true }; var building = new Building { Code = "LAB-CENTER", Name = "实验中心", CampusId = campus.Id }; var classroom = new Classroom { Code = "LAB-CENTER-301", Name = "301", BuildingId = building.Id, Capacity = 30 }; _db.AddRange( user, campus, college, major, administrativeClass, student, teacher, course, term, building, classroom); await _db.SaveChangesAsync(); var task = new TeachingTask { TaskNumber = "LAB-CALENDAR-TASK", Name = "嵌入式系统实验教学班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 30, Status = TeachingTaskStatus.Published, Teachers = [ new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true } ], Classes = [ new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id } ] }; _db.AddRange( task, new ScheduleTimeSlot { AcademicTermId = term.Id, PeriodNumber = 3, Name = "第三节", StartsAt = new TimeOnly(10, 0), EndsAt = new TimeOnly(10, 45) }, new ScheduleTimeSlot { AcademicTermId = term.Id, PeriodNumber = 4, Name = "第四节", StartsAt = new TimeOnly(10, 55), EndsAt = new TimeOnly(11, 40) }); await _db.SaveChangesAsync(); var project = new ExperimentProject { TeachingTaskId = task.Id, Code = "LAB-SELF-01", Name = "自主硬件实验", ArrangementMode = ExperimentArrangementMode.SelfScheduled, StartDate = termStart, EndDate = termStart.AddDays(14), Status = ExperimentProjectStatus.Published, PublishedAt = DateTime.UtcNow }; var session = new ExperimentSession { ExperimentProject = project, ClassroomId = classroom.Id, SessionDate = termStart.AddDays(2), StartPeriod = 3, PeriodCount = 2, Capacity = 30, ReservedCount = 1 }; session.Bookings.Add(new ExperimentBooking { ExperimentProject = project, StudentId = student.Id, Status = ExperimentBookingStatus.Booked, BookedAt = DateTime.UtcNow }); _db.Add(session); await _db.SaveChangesAsync(); var result = await CreateService().BuildAsync( user, CancellationToken.None); Assert.NotNull(result); Assert.Equal(1, result.EventCount); var ics = Encoding.UTF8.GetString(result.Content); Assert.Contains("SUMMARY:[实验] 自主硬件实验", ics); Assert.Contains("CATEGORIES:实验", ics); Assert.Contains("LOCATION:主校区 · 实验中心 · 301", ics); Assert.Contains("LAB-SELF-01 · 自主预约", ics); } [Fact] public void Access_token_is_bound_to_user_stamp_and_can_be_rotated() { var user = new ApplicationUser { Id = Guid.NewGuid(), UserName = "calendar-user", DisplayName = "订阅用户", CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp() }; var service = CreateService(); var originalToken = service.CreateAccessToken(user); Assert.True(service.IsAccessTokenValid(user, originalToken)); Assert.False(service.IsAccessTokenValid(user, originalToken + "x")); user.CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(); Assert.False(service.IsAccessTokenValid(user, originalToken)); Assert.True(service.IsAccessTokenValid(user, service.CreateAccessToken(user))); user.CalendarSubscriptionStamp = null; Assert.False(service.IsAccessTokenValid(user, originalToken)); } private PersonalCalendarService CreateService() => new( _db, new TimetableDataService(_db), Options.Create(new JwtOptions { Key = "calendar-test-signing-key-with-at-least-thirty-two-characters" })); }