using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; 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; using Microsoft.Extensions.Logging.Abstractions; namespace Jiaowu.Api.Tests; public sealed class ExamArrangementJobControllerTests { [Fact] public async Task Formal_and_makeup_arrangement_are_enqueued_with_outbox_messages() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); var term = new AcademicTerm { Code = "2026-EXAM-JOB", Name = "考试后台任务测试", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 7), EndDate = new DateOnly(2027, 1, 17) }; var formalPlan = new ExamPlan { AcademicTerm = term, Name = "正式考试草稿" }; var makeupPlan = new MakeupExamPlan { AcademicTerm = term, Name = "补考草稿" }; db.AddRange(term, formalPlan, makeupPlan); await db.SaveChangesAsync(); var scope = new ManagerDataScope(); var exams = new ExamsController(db, scope, NoOpAppCache.Instance); var makeup = new MakeupExamsController( db, scope, new MakeupExamEligibilityService(db)); var formalResult = await exams.AutoArrange( formalPlan.Id, new ExamAutoArrangeRequest(), CancellationToken.None); var makeupResult = await makeup.AutoArrange( makeupPlan.Id, new ExamAutoArrangeRequest(), CancellationToken.None); Assert.IsType(formalResult); Assert.IsType(makeupResult); var jobs = await db.ExamArrangementJobs .OrderBy(x => x.Kind) .ToListAsync(); Assert.Equal(2, jobs.Count); Assert.Equal(ExamArrangementKind.FormalExam, jobs[0].Kind); Assert.Equal(formalPlan.Id, jobs[0].ActivePlanId); Assert.Equal(ExamArrangementKind.MakeupExam, jobs[1].Kind); Assert.Equal(makeupPlan.Id, jobs[1].ActivePlanId); Assert.All(jobs, job => Assert.Equal(ExamArrangementJobStatus.Queued, job.Status)); var outbox = await db.BackgroundJobOutboxMessages.ToListAsync(); Assert.Equal(2, outbox.Count); Assert.All(outbox, message => { Assert.Equal(BackgroundJobKind.ExamArrangement, message.JobKind); Assert.Equal(BackgroundJobOutboxState.Pending, message.State); }); Assert.Equal( jobs.Select(x => x.Id).Order(), outbox.Select(x => x.JobId).Order()); } [Fact] public async Task Repeated_formal_arrangement_returns_the_existing_active_job() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); var term = new AcademicTerm { Code = "2026-EXAM-DUP", Name = "考试任务去重测试", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 7), EndDate = new DateOnly(2027, 1, 17) }; var plan = new ExamPlan { AcademicTerm = term, Name = "正式考试草稿" }; db.AddRange(term, plan); await db.SaveChangesAsync(); var controller = new ExamsController( db, new ManagerDataScope(), NoOpAppCache.Instance); var first = Assert.IsType( await controller.AutoArrange( plan.Id, new ExamAutoArrangeRequest(), CancellationToken.None)); var second = Assert.IsType( await controller.AutoArrange( plan.Id, new ExamAutoArrangeRequest(), CancellationToken.None)); Assert.Equal(202, first.StatusCode); Assert.Equal(202, second.StatusCode); Assert.Equal(1, await db.ExamArrangementJobs.CountAsync()); Assert.Equal(1, await db.BackgroundJobOutboxMessages.CountAsync()); } [Fact] public async Task Processor_records_business_failure_and_releases_active_plan() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); var term = new AcademicTerm { Code = "2026-EXAM-FAIL", Name = "考试任务失败测试", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 7), EndDate = new DateOnly(2027, 1, 17) }; var plan = new ExamPlan { AcademicTerm = term, Name = "缺少节次配置的考试计划" }; var job = new ExamArrangementJob { Kind = ExamArrangementKind.FormalExam, PlanId = plan.Id, ActivePlanId = plan.Id, AssignClassrooms = true, AssignInvigilators = true }; db.AddRange(term, plan, job); await db.SaveChangesAsync(); var processor = new ExamArrangementJobProcessor( db, new ExamArrangementService(db), new MakeupExamArrangementService(db), NoOpAppCache.Instance, NullLogger.Instance); await processor.ProcessAsync(job.Id, CancellationToken.None); db.ChangeTracker.Clear(); var failed = await db.ExamArrangementJobs.SingleAsync(); Assert.Equal(ExamArrangementJobStatus.Failed, failed.Status); Assert.Null(failed.ActivePlanId); Assert.Equal("编排失败", failed.CurrentStep); Assert.False(string.IsNullOrWhiteSpace(failed.ErrorMessage)); Assert.NotNull(failed.CompletedAt); } private sealed class ManagerDataScope : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( Guid.NewGuid(), "考试管理员", null, DataScope.All, new HashSet { SystemRoles.AcademicAdmin }); } }