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 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" })); }