Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/PersonalCalendarServiceTests.cs
T
biss 40b8cb6e3c 已完善个人教学日历订阅。
主要能力:
教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。
自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。
采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。
支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。
订阅地址按实际前端 API 地址生成,兼容独立部署域名。
已增加窄屏弹窗最大宽度和纵向操作布局。
2026-07-26 17:22:58 +08:00

286 lines
9.4 KiB
C#

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