Files
biss 9e0d4062a4 自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
任务参数、状态和结果持久化到 MySQL。
接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
服务重启后可恢复未完成任务。
前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
编排期间禁止修改、删除或发布对应计划。
补考原有“一键生成”保留,并与编排任务互斥。
运维后台增加“考试与补考编排”失败任务筛选。
2026-07-27 21:40:14 +08:00

273 lines
9.1 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.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExamDeletionControllerTests
{
[Fact]
public async Task RemoveSessionsBatch_ValidatesCompleteSelectionBeforeRemoving()
{
await using var fixture = await Fixture.CreateAsync();
var controller = fixture.CreateController();
var selectedIds = fixture.DraftPlan.Sessions
.Select(x => x.Id)
.ToArray();
var otherPlanSessionId = fixture.PublishedPlan.Sessions.Single().Id;
var invalidResult = await controller.RemoveSessionsBatch(
fixture.DraftPlan.Id,
new RemoveExamSessionsBatchRequest(
[selectedIds[0], otherPlanSessionId]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(invalidResult);
Assert.Equal(2, await fixture.Db.ExamSessions.CountAsync(
x => x.ExamPlanId == fixture.DraftPlan.Id));
var result = Assert.IsType<OkObjectResult>(
await controller.RemoveSessionsBatch(
fixture.DraftPlan.Id,
new RemoveExamSessionsBatchRequest(selectedIds),
CancellationToken.None));
Assert.Equal(2, ReadIntProperty(result.Value!, "removedCount"));
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
x => x.ExamPlanId == fixture.DraftPlan.Id));
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
x => selectedIds.Contains(x.ExamSessionId)));
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == fixture.DraftPlan.Id));
}
[Fact]
public async Task RemoveSessionsBatch_RejectsPublishedPlan()
{
await using var fixture = await Fixture.CreateAsync();
var controller = fixture.CreateController();
var sessionId = fixture.PublishedPlan.Sessions.Single().Id;
var result = await controller.RemoveSessionsBatch(
fixture.PublishedPlan.Id,
new RemoveExamSessionsBatchRequest([sessionId]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.True(await fixture.Db.ExamSessions.AnyAsync(
x => x.Id == sessionId));
}
[Fact]
public async Task DeletePlan_DeletesDraftWithSessions_AndRejectsPublishedPlan()
{
await using var fixture = await Fixture.CreateAsync();
var controller = fixture.CreateController();
var draftPlanId = fixture.DraftPlan.Id;
var publishedPlanId = fixture.PublishedPlan.Id;
var draftSessionIds = fixture.DraftPlan.Sessions
.Select(x => x.Id)
.ToArray();
var publishedResult = await controller.DeletePlan(
publishedPlanId,
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(publishedResult);
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == publishedPlanId));
var draftResult = await controller.DeletePlan(
draftPlanId,
CancellationToken.None);
Assert.IsType<NoContentResult>(draftResult);
Assert.False(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == draftPlanId));
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
x => x.ExamPlanId == draftPlanId));
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
x => draftSessionIds.Contains(x.ExamSessionId)));
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
x => x.Id == publishedPlanId));
}
private static int ReadIntProperty(object value, string name) =>
(int)value.GetType().GetProperty(name)!.GetValue(value)!;
private sealed class Fixture : IAsyncDisposable
{
private readonly SqliteConnection connection;
private Fixture(
SqliteConnection connection,
AppDbContext db,
ExamPlan draftPlan,
ExamPlan publishedPlan)
{
this.connection = connection;
Db = db;
DraftPlan = draftPlan;
PublishedPlan = publishedPlan;
}
public AppDbContext Db { get; }
public ExamPlan DraftPlan { get; }
public ExamPlan PublishedPlan { get; }
public static async Task<Fixture> CreateAsync()
{
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 = "CS",
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, 20)
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var firstTask = CreateTask(
term.Id,
course.Id,
"2026-1-CS101-01");
var secondTask = CreateTask(
term.Id,
course.Id,
"2026-1-CS101-02");
var invigilator = new Teacher
{
TeacherNumber = "T001",
Name = "张老师",
CollegeId = college.Id
};
var firstDraftSession = CreateSession(
firstTask.Id,
new DateOnly(2027, 1, 8));
firstDraftSession.Invigilators =
[
new ExamSessionInvigilator
{
TeacherId = invigilator.Id
}
];
var draftPlan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "期末考试草稿",
Sessions =
[
firstDraftSession,
CreateSession(secondTask.Id, new DateOnly(2027, 1, 9))
]
};
var publishedPlan = new ExamPlan
{
AcademicTermId = term.Id,
Name = "已发布期末考试",
Status = ExamPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Sessions =
[
CreateSession(firstTask.Id, new DateOnly(2027, 1, 10))
]
};
db.AddRange(
college,
term,
course,
firstTask,
secondTask,
invigilator,
draftPlan,
publishedPlan);
await db.SaveChangesAsync();
return new Fixture(
connection,
db,
draftPlan,
publishedPlan);
}
public ExamsController CreateController() => new(
Db,
new ManagerDataScope(),
NoOpAppCache.Instance);
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
private static TeachingTask CreateTask(
Guid termId,
Guid courseId,
string number) => new()
{
AcademicTermId = termId,
CourseId = courseId,
TaskNumber = number,
Name = $"教学班 {number}",
Capacity = 60,
Status = TeachingTaskStatus.Published
};
private static ExamSession CreateSession(
Guid taskId,
DateOnly examDate) => new()
{
TeachingTaskId = taskId,
ExamDate = examDate,
StartPeriod = 1,
PeriodCount = 2,
StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)),
EndsAt = examDate.ToDateTime(new TimeOnly(9, 50)),
RequiredInvigilatorCount = 2
};
}
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"考试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.AcademicAdmin]));
}
}