Files
2026-07-28 21:25:21 +08:00

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