新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12) 接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153) 缓存学生激活选项、基础数据、公开课表和课表选项。 个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。 基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。 新增 /health/cache,Redis 故障不影响 /health/ready。 Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。 更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
246 lines
8.6 KiB
C#
246 lines
8.6 KiB
C#
using Jiaowu.Api.Controllers;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Auth;
|
|
using Jiaowu.Api.Infrastructure.Caching;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Tests;
|
|
|
|
public sealed class TeachingTasksControllerTests
|
|
{
|
|
[Fact]
|
|
public async Task Delete_AllowsArchivedTask_ButRejectsPublishedTask()
|
|
{
|
|
await using var database = await TestDatabase.CreateAsync();
|
|
var archived = await database.AddTaskAsync("ARCHIVED", TeachingTaskStatus.Closed);
|
|
var published = await database.AddTaskAsync("PUBLISHED", TeachingTaskStatus.Published);
|
|
|
|
var deleteArchived = await database.Controller.Delete(
|
|
archived.Id,
|
|
CancellationToken.None);
|
|
var deletePublished = await database.Controller.Delete(
|
|
published.Id,
|
|
CancellationToken.None);
|
|
|
|
Assert.IsType<NoContentResult>(deleteArchived);
|
|
Assert.IsType<ConflictObjectResult>(deletePublished);
|
|
Assert.False(await database.Db.TeachingTasks.AnyAsync(x => x.Id == archived.Id));
|
|
Assert.True(await database.Db.TeachingTasks.AnyAsync(x => x.Id == published.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unarchive_RestoresArchivedTaskToPublished()
|
|
{
|
|
await using var database = await TestDatabase.CreateAsync();
|
|
var task = await database.AddTaskAsync("RESTORE", TeachingTaskStatus.Closed);
|
|
|
|
var result = await database.Controller.Unarchive(task.Id, CancellationToken.None);
|
|
|
|
Assert.IsType<NoContentResult>(result);
|
|
await database.Db.Entry(task).ReloadAsync();
|
|
Assert.Equal(TeachingTaskStatus.Published, task.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Batch_UnarchivesOnlyArchivedTasks()
|
|
{
|
|
await using var database = await TestDatabase.CreateAsync();
|
|
var first = await database.AddTaskAsync("FIRST", TeachingTaskStatus.Closed);
|
|
var second = await database.AddTaskAsync("SECOND", TeachingTaskStatus.Closed);
|
|
|
|
var result = await database.Controller.Batch(
|
|
new TeachingTaskBatchRequest(
|
|
TeachingTaskBatchOperation.Unarchive,
|
|
[first.Id, second.Id]),
|
|
CancellationToken.None);
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
Assert.All(
|
|
await database.Db.TeachingTasks
|
|
.Where(x => x.Id == first.Id || x.Id == second.Id)
|
|
.ToListAsync(),
|
|
task => Assert.Equal(TeachingTaskStatus.Published, task.Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Options_ReturnsAllPublishedTasksWithoutListPageTruncation()
|
|
{
|
|
await using var database = await TestDatabase.CreateAsync();
|
|
for (var index = 1; index <= 125; index++)
|
|
{
|
|
await database.AddTaskAsync(
|
|
$"OPTION-{index:D3}",
|
|
TeachingTaskStatus.Published);
|
|
}
|
|
await database.AddTaskAsync("DRAFT", TeachingTaskStatus.Draft);
|
|
|
|
var result = await database.Controller.GetOptions(
|
|
database.TermId,
|
|
TeachingTaskStatus.Published,
|
|
CancellationToken.None);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var items = Assert.IsAssignableFrom<System.Collections.IEnumerable>(ok.Value);
|
|
Assert.Equal(125, items.Cast<object>().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Options_PreservesCollegeDataScope()
|
|
{
|
|
await using var database = await TestDatabase.CreateAsync(collegeScoped: true);
|
|
await database.AddTaskAsync("IN-SCOPE", TeachingTaskStatus.Published);
|
|
var otherCollege = new College { Code = "OTHER", Name = "其他学院" };
|
|
var otherCourse = new Course
|
|
{
|
|
Code = "OTHER101",
|
|
Name = "范围外课程",
|
|
College = otherCollege,
|
|
Nature = CourseNature.MajorRequired,
|
|
TotalHours = 32,
|
|
LectureHours = 32
|
|
};
|
|
database.Db.Add(new TeachingTask
|
|
{
|
|
TaskNumber = "TASK-OUT-OF-SCOPE",
|
|
Name = "范围外教学任务",
|
|
AcademicTermId = database.TermId,
|
|
Course = otherCourse,
|
|
Capacity = 40,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeeklyHours = 2,
|
|
Status = TeachingTaskStatus.Published,
|
|
PublishedAt = DateTime.UtcNow
|
|
});
|
|
await database.Db.SaveChangesAsync();
|
|
|
|
var result = await database.Controller.GetOptions(
|
|
database.TermId,
|
|
TeachingTaskStatus.Published,
|
|
CancellationToken.None);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var items = Assert.IsAssignableFrom<System.Collections.IEnumerable>(ok.Value);
|
|
Assert.Single(items.Cast<object>());
|
|
}
|
|
|
|
private sealed class TestDatabase : IAsyncDisposable
|
|
{
|
|
private readonly SqliteConnection connection;
|
|
private readonly AcademicTerm term;
|
|
private readonly Course course;
|
|
|
|
private TestDatabase(
|
|
SqliteConnection connection,
|
|
AppDbContext db,
|
|
AcademicTerm term,
|
|
Course course,
|
|
ICurrentUserDataScope dataScope)
|
|
{
|
|
this.connection = connection;
|
|
Db = db;
|
|
this.term = term;
|
|
this.course = course;
|
|
Controller = new TeachingTasksController(
|
|
db,
|
|
dataScope,
|
|
NoOpAppCache.Instance);
|
|
}
|
|
|
|
public AppDbContext Db { get; }
|
|
public TeachingTasksController Controller { get; }
|
|
public Guid TermId => term.Id;
|
|
|
|
public static async Task<TestDatabase> CreateAsync(bool collegeScoped = false)
|
|
{
|
|
var connection = new SqliteConnection("Data Source=:memory:");
|
|
await connection.OpenAsync();
|
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseSqlite(connection)
|
|
.Options;
|
|
var db = new AppDbContext(options);
|
|
await db.Database.EnsureCreatedAsync();
|
|
|
|
var college = new College { Code = "TEST", Name = "测试学院" };
|
|
var term = new AcademicTerm
|
|
{
|
|
Code = "2026-1",
|
|
Name = "2026—2027 学年第一学期",
|
|
AcademicYear = "2026-2027",
|
|
Season = TermSeason.Autumn,
|
|
StartDate = new DateOnly(2026, 9, 1),
|
|
EndDate = new DateOnly(2027, 1, 15)
|
|
};
|
|
var course = new Course
|
|
{
|
|
Code = "TEST101",
|
|
Name = "测试课程",
|
|
CollegeId = college.Id,
|
|
Nature = CourseNature.MajorRequired,
|
|
TotalHours = 32,
|
|
LectureHours = 32
|
|
};
|
|
db.AddRange(college, term, course);
|
|
await db.SaveChangesAsync();
|
|
return new TestDatabase(
|
|
connection,
|
|
db,
|
|
term,
|
|
course,
|
|
new TestDataScope(collegeScoped ? college.Id : null));
|
|
}
|
|
|
|
public async Task<TeachingTask> AddTaskAsync(
|
|
string suffix,
|
|
TeachingTaskStatus status)
|
|
{
|
|
var task = new TeachingTask
|
|
{
|
|
TaskNumber = $"TASK-{suffix}",
|
|
Name = $"教学任务 {suffix}",
|
|
AcademicTermId = term.Id,
|
|
CourseId = course.Id,
|
|
Capacity = 40,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeeklyHours = 2,
|
|
Status = status,
|
|
PublishedAt = status == TeachingTaskStatus.Draft
|
|
? null
|
|
: DateTime.UtcNow
|
|
};
|
|
Db.TeachingTasks.Add(task);
|
|
await Db.SaveChangesAsync();
|
|
return task;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Db.DisposeAsync();
|
|
await connection.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
private sealed class TestDataScope : ICurrentUserDataScope
|
|
{
|
|
public TestDataScope(Guid? restrictedCollegeId = null)
|
|
{
|
|
Current = new CurrentUserScope(
|
|
Guid.NewGuid(),
|
|
"测试管理员",
|
|
restrictedCollegeId,
|
|
restrictedCollegeId.HasValue ? DataScope.College : DataScope.All,
|
|
new HashSet<string>(
|
|
restrictedCollegeId.HasValue
|
|
? [SystemRoles.CollegeAdmin]
|
|
: [SystemRoles.SuperAdmin]));
|
|
}
|
|
|
|
public CurrentUserScope Current { get; }
|
|
}
|
|
}
|