主要能力: 教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。 自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。 采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。 支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。 订阅地址按实际前端 API 地址生成,兼容独立部署域名。 已增加窄屏弹窗最大宽度和纵向操作布局。
286 lines
9.4 KiB
C#
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"
|
|
}));
|
|
}
|