205 lines
12 KiB
C#
205 lines
12 KiB
C#
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<AppDbContext>().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<ArchiveOperationResult[]> Apply(string kind, Guid[] ids, bool archived = true, ICurrentUserDataScope? scope = null)
|
|
{
|
|
var response = Assert.IsType<OkObjectResult>(await Controller(scope).Apply(kind, new(ids, archived, "测试原因"), default));
|
|
return Assert.IsAssignableFrom<IEnumerable<object>>(response.Value).Cast<ArchiveOperationResult>().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<string> Transcript() => JsonSerializer.Serialize(Assert.IsType<OkObjectResult>(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<OkObjectResult>((await controller.GetOptions()).Result);
|
|
Assert.Equal(0, JsonSerializer.SerializeToElement(options.Value).GetArrayLength());
|
|
var archived = Assert.IsType<OkObjectResult>((await controller.Get(isArchived: true)).Result);
|
|
Assert.Equal(1, JsonSerializer.SerializeToElement(archived.Value).GetProperty("Total").GetInt32());
|
|
var current = Assert.IsType<OkObjectResult>((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<OkObjectResult>((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<BadRequestObjectResult>(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<NotFoundResult>(await Controller(new ArchiveTestScope(Guid.NewGuid())).History("courses", course.Id, default));
|
|
var response = Assert.IsType<OkObjectResult>(await Controller().History("courses", course.Id, default));
|
|
Assert.Single(Assert.IsAssignableFrom<IEnumerable<ArchiveEvent>>(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<FileContentResult>(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<ObjectResult>(result.Result);
|
|
var details = Assert.IsType<ValidationProblemDetails>(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<FileContentResult>(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<DevelopmentSqliteMigrator>.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<string> { role ?? (collegeId.HasValue ? SystemRoles.CollegeAdmin : SystemRoles.SuperAdmin) });
|
|
}
|