using System.Text.Json; using ClosedXML.Excel; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Academic; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; namespace Jiaowu.Api.Tests; public sealed class ArchivesControllerTests : IAsyncDisposable { private readonly SqliteConnection connection = new("Data Source=:memory:"); private readonly AppDbContext db; private readonly ArchiveTestScope school = new(); private readonly College college = new() { Code = "C", Name = "学院" }; private readonly AcademicTerm term = new() { Code = "T", Name = "历史学期", AcademicYear = "2025", Season = TermSeason.Autumn, StartDate = new(2025, 9, 1), EndDate = new(2026, 1, 1) }; private readonly Course course; public ArchivesControllerTests() { connection.Open(); db = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); db.Database.EnsureCreated(); course = new() { Code = "C1", Name = "课程", CollegeId = college.Id, Nature = CourseNature.MajorRequired, Credits = 3 }; db.AddRange(college, term, course); db.SaveChanges(); } private ArchivesController Controller(ICurrentUserDataScope? scope = null) => new(db, scope ?? school, NoOpAppCache.Instance); private async Task Apply(string kind, Guid[] ids, bool archived = true, ICurrentUserDataScope? scope = null) { var response = Assert.IsType(await Controller(scope).Apply(kind, new(ids, archived, "测试原因"), default)); return Assert.IsAssignableFrom>(response.Value).Cast().ToArray(); } [Fact] public async Task Teacher_batch_reports_blockers_and_keeps_audit_after_restore() { var retired = new Teacher { TeacherNumber = "R", Name = "退休教师", CollegeId = college.Id, Status = TeacherStatus.Retired }; var active = new Teacher { TeacherNumber = "A", Name = "在职教师", CollegeId = college.Id }; db.AddRange(retired, active); await db.SaveChangesAsync(); var results = await Apply("teachers", [retired.Id, active.Id]); Assert.True(results[0].Success); Assert.False(results[1].Success); Assert.True((await db.Teachers.FindAsync(retired.Id))!.IsArchived); Assert.False((await db.Teachers.FindAsync(active.Id))!.IsArchived); Assert.True((await Apply("teachers", [retired.Id], false))[0].Success); var restored = (await db.Teachers.FindAsync(retired.Id))!; Assert.Equal(TeacherStatus.Retired, restored.Status); Assert.Null(restored.ArchivedAt); Assert.Equal(2, await db.ArchiveEvents.CountAsync()); } [Fact] public async Task Permissions_block_other_colleges_public_courses_and_non_managers() { Assert.False((await Apply("courses", [course.Id], scope: new ArchiveTestScope(Guid.NewGuid())))[0].Success); var entity = (await db.Courses.FindAsync(course.Id))!; entity.Nature = CourseNature.GeneralRequired; await db.SaveChangesAsync(); Assert.False((await Apply("courses", [course.Id], scope: new ArchiveTestScope(college.Id)))[0].Success); Assert.False((await Apply("courses", [course.Id], scope: new ArchiveTestScope(role: SystemRoles.Teacher)))[0].Success); Assert.Empty(await db.ArchiveEvents.ToArrayAsync()); } [Fact] public async Task Apply_rechecks_state_after_preview_and_is_idempotent() { var service = new ArchiveService(db, school); Assert.Empty((await service.InspectAsync("courses", course.Id, false, default))!.Value.Preview.Blockers); var task = Task(TeachingTaskStatus.Draft); db.Add(task); await db.SaveChangesAsync(); Assert.False((await Apply("courses", [course.Id]))[0].Success); db.TeachingTasks.Remove((await db.TeachingTasks.FindAsync(task.Id))!); await db.SaveChangesAsync(); Assert.True((await Apply("courses", [course.Id]))[0].Success); Assert.True((await Apply("courses", [course.Id]))[0].Success); Assert.Single(await db.ArchiveEvents.ToArrayAsync()); Assert.True((await db.Courses.FindAsync(course.Id))!.IsEnabled); } [Fact] public async Task Archived_entities_preserve_published_transcript_and_grade_calculations() { var major = new Major { Code = "M", Name = "专业", CollegeId = college.Id, DegreeType = "学士" }; var group = new AdministrativeClass { Code = "G", Name = "班级", MajorId = major.Id }; var studentScope = new ArchiveTestScope(role: SystemRoles.Student); db.Users.Add(new ApplicationUser { Id = studentScope.Current.UserId, DisplayName = "学生", UserName = "student" }); var student = new Student { StudentNumber = "S", Name = "学生", AdministrativeClassId = group.Id, UserId = studentScope.Current.UserId, Status = StudentStatus.Graduated }; var task = Task(TeachingTaskStatus.Closed); var sheet = new GradeSheet { TeachingTaskId = task.Id, Status = GradeSheetStatus.Published }; var record = new GradeRecord { GradeSheetId = sheet.Id, StudentId = student.Id, TotalScore = 90, GradePoint = 4 }; db.AddRange(major, group, student, task, sheet, record); await db.SaveChangesAsync(); async Task Transcript() => JsonSerializer.Serialize(Assert.IsType(await new GradesController(db, studentScope).GetStudentTranscript(null, default)).Value); var before = await Transcript(); foreach (var (kind, id) in new[] { ("grades", sheet.Id), ("students", student.Id), ("courses", course.Id), ("terms", term.Id) }) Assert.True((await Apply(kind, [id]))[0].Success); Assert.Equal(before, await Transcript()); var stored = (await db.GradeSheets.FindAsync(sheet.Id))!; Assert.Equal(GradeSheetStatus.Published, stored.Status); Assert.Equal(4, (await db.GradeRecords.FindAsync(record.Id))!.GradePoint); Assert.True((await Apply("grades", [sheet.Id], false))[0].Success); Assert.Equal(GradeSheetStatus.Published, (await db.GradeSheets.FindAsync(sheet.Id))!.Status); } [Fact] public async Task Term_requires_closed_tasks_and_published_grade_sheets() { var task = Task(TeachingTaskStatus.Closed); db.Add(task); await db.SaveChangesAsync(); Assert.False((await Apply("terms", [term.Id]))[0].Success); var sheet = new GradeSheet { TeachingTaskId = task.Id }; db.Add(sheet); await db.SaveChangesAsync(); Assert.False((await Apply("terms", [term.Id]))[0].Success); Assert.False((await Apply("grades", [sheet.Id]))[0].Success); var loaded = (await db.GradeSheets.FindAsync(sheet.Id))!; loaded.Status = GradeSheetStatus.Published; await db.SaveChangesAsync(); Assert.True((await Apply("terms", [term.Id]))[0].Success); } [Fact] public async Task Archived_courses_are_hidden_from_options_but_available_in_filtered_history() { Assert.True((await Apply("courses", [course.Id]))[0].Success); var controller = new CoursesController(db, school, NoOpAppCache.Instance); var options = Assert.IsType((await controller.GetOptions()).Result); Assert.Equal(0, JsonSerializer.SerializeToElement(options.Value).GetArrayLength()); var archived = Assert.IsType((await controller.Get(isArchived: true)).Result); Assert.Equal(1, JsonSerializer.SerializeToElement(archived.Value).GetProperty("Total").GetInt32()); var current = Assert.IsType((await controller.Get(isArchived: false)).Result); Assert.Equal(0, JsonSerializer.SerializeToElement(current.Value).GetProperty("Total").GetInt32()); Assert.True((await Apply("courses", [course.Id], false))[0].Success); options = Assert.IsType((await controller.GetOptions()).Result); Assert.Equal(1, JsonSerializer.SerializeToElement(options.Value).GetArrayLength()); } [Fact] public async Task History_has_its_own_scope_check_and_empty_reason_cannot_mutate() { Assert.IsType(await Controller().Apply("courses", new([course.Id], true, " "), default)); Assert.Empty(await db.ArchiveEvents.ToArrayAsync()); Assert.True((await Apply("courses", [course.Id]))[0].Success); Assert.IsType(await Controller(new ArchiveTestScope(Guid.NewGuid())).History("courses", course.Id, default)); var response = Assert.IsType(await Controller().History("courses", course.Id, default)); Assert.Single(Assert.IsAssignableFrom>(response.Value)); } [Fact] public async Task Term_excel_cannot_overwrite_archived_data_and_export_matches_archive_filter() { Assert.True((await Apply("terms", [term.Id]))[0].Success); var controller = new BaseDataExcelController(db, NoOpAppCache.Instance); var file = Assert.IsType(await controller.Export("terms", default, true)); using var workbook = new XLWorkbook(new MemoryStream(file.FileContents)); Assert.Equal(term.Code, workbook.Worksheet(1).Cell(2, 1).GetString()); workbook.Worksheet(1).Cell(2, 2).Value = "不应被覆盖"; using var stream = new MemoryStream(); workbook.SaveAs(stream); stream.Position = 0; var result = await controller.Import("terms", new FormFile(stream, 0, stream.Length, "file", "terms.xlsx"), default); var error = Assert.IsAssignableFrom(result.Result); var details = Assert.IsType(error.Value); Assert.Contains(details.Errors.SelectMany(x => x.Value), x => x.Contains("已归档")); db.ChangeTracker.Clear(); Assert.Equal(term.Name, (await db.AcademicTerms.FindAsync(term.Id))!.Name); var empty = Assert.IsType(await controller.Export("terms", default, false)); using var emptyWorkbook = new XLWorkbook(new MemoryStream(empty.FileContents)); Assert.True(emptyWorkbook.Worksheet(1).Cell(2, 1).IsEmpty()); } [Fact] public async Task Sqlite_upgrade_preserves_existing_rows_and_is_repeatable() { await db.Database.ExecuteSqlRawAsync("DROP TABLE ArchiveEvents"); // Identifiers below come exclusively from this fixed test schema. #pragma warning disable EF1002 foreach (var table in new[] { "Teachers", "Students", "Courses", "GradeSheets", "AcademicTerms" }) { if (table != "AcademicTerms") { await db.Database.ExecuteSqlRawAsync($"DROP INDEX IX_{table}_IsArchived"); await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} DROP COLUMN IsArchived"); await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} DROP COLUMN ArchivedAt"); } await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} DROP COLUMN ArchivedByUserId"); await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} DROP COLUMN ArchiveReason"); } #pragma warning restore EF1002 var migrator = new DevelopmentSqliteMigrator(db, NullLogger.Instance); await migrator.MigrateAsync(); await migrator.MigrateAsync(); db.ChangeTracker.Clear(); Assert.Equal("课程", (await db.Courses.FindAsync(course.Id))!.Name); Assert.True((await Apply("courses", [course.Id]))[0].Success); } private TeachingTask Task(TeachingTaskStatus status) => new() { TaskNumber = Guid.NewGuid().ToString(), Name = "教学班", CourseId = course.Id, AcademicTermId = term.Id, Status = status }; public async ValueTask DisposeAsync() { await db.DisposeAsync(); await connection.DisposeAsync(); } } internal sealed class ArchiveTestScope(Guid? collegeId = null, string? role = null) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new(Guid.NewGuid(), "测试管理员", collegeId, collegeId.HasValue ? DataScope.College : DataScope.All, new HashSet { role ?? (collegeId.HasValue ? SystemRoles.CollegeAdmin : SystemRoles.SuperAdmin) }); }