using System.Security.Cryptography; using System.Text.Json; using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Jiaowu.Api.Tests; public sealed class OfficialDocumentTests { [Theory] [InlineData(typeof(IssueOfficialDocumentRequest), "Purpose")] [InlineData(typeof(SelfIssueOfficialDocumentRequest), "Purpose")] [InlineData(typeof(InvalidateOfficialDocumentRequest), "Reason")] [InlineData(typeof(ReissueOfficialDocumentRequest), "Reason")] [InlineData(typeof(ReissueOfficialDocumentRequest), "Purpose")] public void RequestRecords_DefineValidationOnPrimaryConstructorParameters( Type requestType, string parameterName) { var constructor = Assert.Single(requestType.GetConstructors()); var parameter = Assert.Single(constructor.GetParameters() .Where(x => x.Name == parameterName)); Assert.NotEmpty(parameter.GetCustomAttributes(typeof(ValidationAttribute), false)); var property = requestType.GetProperty(parameterName); Assert.NotNull(property); Assert.Empty(property.GetCustomAttributes(typeof(ValidationAttribute), false)); } [Theory] [InlineData(OfficialDocumentType.Transcript)] [InlineData(OfficialDocumentType.StudentStatusCertificate)] public void PdfGenerator_CreatesPdfWithStableHash(OfficialDocumentType type) { var options = CreateOptions(); var generator = new OfficialDocumentPdfGenerator(options); var snapshot = CreateSnapshot(type); var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code"); Assert.True(result.Content.Length > 5_000); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4)); Assert.Equal( Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(), result.Sha256); } [Fact] public async Task Service_IssuesTranscriptFromPublishedGradesOnly() { await using var fixture = await Fixture.CreateAsync(); var document = await fixture.Service.CreateAsync( fixture.Student.Id, OfficialDocumentType.Transcript, "升学申请", fixture.Manager.Id, fixture.Manager.DisplayName, "https://jw.example.edu", null, CancellationToken.None); var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson); Assert.StartsWith("MXU-TR-", document.DocumentNumber); Assert.Equal(64, document.VerificationCodeHash.Length); Assert.Equal("升学申请", document.Purpose); Assert.Single(snapshot.Grades); Assert.Equal("程序设计基础", snapshot.Grades[0].CourseName); Assert.Equal(4m, snapshot.EarnedCredits); Assert.Equal(3.7m, snapshot.GradePointAverage); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(document.PdfContent, 0, 4)); } [Fact] public async Task Download_IsLogged_AndInvalidDocumentCannotBeDownloaded() { await using var fixture = await Fixture.CreateAsync(); var document = await fixture.IssueAndSaveAsync(); var controller = fixture.CreateController(); var result = await controller.Download(document.Id, CancellationToken.None); Assert.IsType(result); var log = await fixture.Db.OfficialDocumentDownloads.SingleAsync(); Assert.Equal(document.Id, log.OfficialDocumentId); Assert.Equal(fixture.Manager.Id, log.DownloadedByUserId); document.Status = OfficialDocumentStatus.Invalidated; await fixture.Db.SaveChangesAsync(); var invalidResult = await controller.Download(document.Id, CancellationToken.None); Assert.IsType(invalidResult); Assert.Single(await fixture.Db.OfficialDocumentDownloads.ToListAsync()); } [Fact] public async Task Verify_ReturnsMaskedIdentityAndCurrentRevocationState() { await using var fixture = await Fixture.CreateAsync(); const string verificationCode = "1234567890abcdef1234567890abcdef1234567890abcdef"; var snapshot = CreateSnapshot(OfficialDocumentType.StudentStatusCertificate); var document = new OfficialDocument { DocumentNumber = snapshot.DocumentNumber, VerificationCodeHash = OfficialDocumentService.HashVerificationCode(verificationCode), Type = snapshot.Type, Status = OfficialDocumentStatus.Valid, StudentId = fixture.Student.Id, IssuedByUserId = fixture.Manager.Id, IssuedAt = snapshot.IssuedAt, SnapshotJson = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions(JsonSerializerDefaults.Web)), PdfContent = [1, 2, 3], PdfSha256 = new string('a', 64) }; fixture.Db.OfficialDocuments.Add(document); await fixture.Db.SaveChangesAsync(); var controller = fixture.CreateController(); var action = await controller.Verify(verificationCode, CancellationToken.None); var ok = Assert.IsType(action); using var json = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); Assert.Equal("张*", json.RootElement.GetProperty("StudentName").GetString()); Assert.Equal("20*****01", json.RootElement.GetProperty("StudentNumber").GetString()); Assert.True(json.RootElement.GetProperty("Valid").GetBoolean()); var numberAction = await controller.Verify(document.DocumentNumber, CancellationToken.None); var numberOk = Assert.IsType(numberAction); using var numberJson = JsonDocument.Parse(JsonSerializer.Serialize(numberOk.Value)); Assert.True(numberJson.RootElement.GetProperty("Valid").GetBoolean()); document.Status = OfficialDocumentStatus.Invalidated; document.InvalidatedAt = DateTime.UtcNow; await fixture.Db.SaveChangesAsync(); action = await controller.Verify(verificationCode, CancellationToken.None); ok = Assert.IsType(action); using var invalidJson = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); Assert.False(invalidJson.RootElement.GetProperty("Valid").GetBoolean()); } [Fact] public async Task Reissue_SupersedesOriginalAndCreatesSingleLinkedReplacement() { await using var fixture = await Fixture.CreateAsync(); var original = await fixture.IssueAndSaveAsync(); var controller = fixture.CreateController(); var result = await controller.Reissue( original.Id, new ReissueOfficialDocumentRequest("成绩更正后重新签发", null), CancellationToken.None); Assert.IsType(result); fixture.Db.ChangeTracker.Clear(); var savedOriginal = await fixture.Db.OfficialDocuments.SingleAsync(x => x.Id == original.Id); var replacement = await fixture.Db.OfficialDocuments.SingleAsync( x => x.ReissuedFromDocumentId == original.Id); Assert.Equal(OfficialDocumentStatus.Superseded, savedOriginal.Status); Assert.Equal(OfficialDocumentStatus.Valid, replacement.Status); Assert.NotEqual(savedOriginal.DocumentNumber, replacement.DocumentNumber); Assert.NotEqual(savedOriginal.VerificationCodeHash, replacement.VerificationCodeHash); var duplicate = await controller.Reissue( original.Id, new ReissueOfficialDocumentRequest("再次重签", null), CancellationToken.None); Assert.IsType(duplicate); Assert.Equal(2, await fixture.Db.OfficialDocuments.CountAsync()); } [Fact] public async Task StudentCanIssueOwnDocument_AndRecentDuplicateIsReused() { await using var fixture = await Fixture.CreateAsync(); var controller = fixture.CreateStudentController(); var request = new SelfIssueOfficialDocumentRequest( OfficialDocumentType.Transcript, " 实习申请 "); var first = await controller.IssueMine(request, CancellationToken.None); Assert.IsType(first); var document = await fixture.Db.OfficialDocuments.SingleAsync(); Assert.Equal(fixture.Student.Id, document.StudentId); Assert.Equal(fixture.StudentUser.Id, document.IssuedByUserId); Assert.Equal("实习申请", document.Purpose); var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson); Assert.Equal("教务处自助签发", snapshot.IssuedByName); Assert.Single(snapshot.Grades); var duplicate = await controller.IssueMine(request, CancellationToken.None); Assert.IsType(duplicate); Assert.Single(await fixture.Db.OfficialDocuments.ToListAsync()); } [Fact] public async Task StudentWithoutLinkedRecord_CannotIssueDocument() { await using var fixture = await Fixture.CreateAsync(); var controller = fixture.CreateStudentController(Guid.NewGuid()); var result = await controller.IssueMine( new SelfIssueOfficialDocumentRequest( OfficialDocumentType.StudentStatusCertificate, null), CancellationToken.None); Assert.IsType(result); Assert.Empty(await fixture.Db.OfficialDocuments.ToListAsync()); } private static OfficialDocumentOptions CreateOptions() => new() { InstitutionName = "明序大学", IssuingOffice = "教务处", DocumentNumberPrefix = "MXU" }; private static OfficialDocumentSnapshot CreateSnapshot(OfficialDocumentType type) => new( "明序大学", "教务处", type == OfficialDocumentType.Transcript ? "MXU-TR-20260726-ABCDEF123456" : "MXU-SC-20260726-ABCDEF123456", type, new DateTime(2026, 7, 26, 8, 30, 0, DateTimeKind.Utc), "教务管理员", "升学申请", new OfficialStudentSnapshot( "202600001", "张明", "男", "计算机学院", "计算机科学与技术", "计科 2026-1 班", 2026, new DateOnly(2026, 9, 1), "在读"), type == OfficialDocumentType.Transcript ? [new OfficialTranscriptRow("2026-2027-1", "CS101", "程序设计基础", 4, 88, 3.7m, "正常")] : [], type == OfficialDocumentType.Transcript ? 4 : 0, type == OfficialDocumentType.Transcript ? 4 : 0, type == OfficialDocumentType.Transcript ? 3.7m : null); private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection connection; private readonly OfficialDocumentOptions options; private Fixture( SqliteConnection connection, AppDbContext db, ApplicationUser manager, ApplicationUser studentUser, Student student, OfficialDocumentOptions options) { this.connection = connection; this.options = options; Db = db; Manager = manager; StudentUser = studentUser; Student = student; Service = new OfficialDocumentService( db, options, new OfficialDocumentPdfGenerator(options)); } public AppDbContext Db { get; } public ApplicationUser Manager { get; } public ApplicationUser StudentUser { get; } public Student Student { get; } public OfficialDocumentService Service { get; } public static async Task CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var db = new AppDbContext(new DbContextOptionsBuilder() .UseSqlite(connection) .Options); await db.Database.EnsureCreatedAsync(); var manager = new ApplicationUser { Id = Guid.NewGuid(), UserName = "academic-admin", NormalizedUserName = "ACADEMIC-ADMIN", DisplayName = "教务管理员" }; var studentUser = new ApplicationUser { Id = Guid.NewGuid(), UserName = "202600001", NormalizedUserName = "202600001", DisplayName = "张明" }; var college = new College { Code = "CS", Name = "计算机学院" }; var major = new Major { Code = "080901", Name = "计算机科学与技术", DegreeType = "工学学士", CollegeId = college.Id }; var administrativeClass = new AdministrativeClass { Code = "CS2026-1", Name = "计科 2026-1 班", MajorId = major.Id, Grade = 2026 }; var student = new Student { StudentNumber = "202600001", Name = "张明", Gender = Gender.Male, AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1), Status = StudentStatus.Active, UserId = studentUser.Id }; 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 }; var task = new TeachingTask { TaskNumber = "2026-CS101-01", Name = "程序设计基础 01 班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 40, Status = TeachingTaskStatus.Closed }; var published = new GradeSheet { TeachingTaskId = task.Id, Status = GradeSheetStatus.Published, Records = [ new GradeRecord { StudentId = student.Id, TotalScore = 88, GradePoint = 3.7m } ] }; var draftTask = new TeachingTask { TaskNumber = "2026-CS102-01", Name = "未发布课程", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 40 }; var draft = new GradeSheet { TeachingTaskId = draftTask.Id, Status = GradeSheetStatus.Approved, Records = [ new GradeRecord { StudentId = student.Id, TotalScore = 99, GradePoint = 4m } ] }; db.AddRange(manager, studentUser, college, major, administrativeClass, student, term, course, task, published, draftTask, draft); await db.SaveChangesAsync(); return new Fixture(connection, db, manager, studentUser, student, CreateOptions()); } public async Task IssueAndSaveAsync() { var document = await Service.CreateAsync( Student.Id, OfficialDocumentType.Transcript, null, Manager.Id, Manager.DisplayName, "https://jw.example.edu", null, CancellationToken.None); Db.OfficialDocuments.Add(document); await Db.SaveChangesAsync(); return document; } public OfficialDocumentsController CreateController() => CreateController(new TestDataScope(Manager.Id)); public OfficialDocumentsController CreateStudentController(Guid? userId = null) => CreateController(new TestStudentDataScope(userId ?? StudentUser.Id)); private OfficialDocumentsController CreateController(ICurrentUserDataScope dataScope) { var controller = new OfficialDocumentsController( Db, dataScope, Service, Options.Create(options)); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; controller.Request.Scheme = "https"; controller.Request.Host = new HostString("jw.example.edu"); controller.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; return controller; } public async ValueTask DisposeAsync() { await Db.DisposeAsync(); await connection.DisposeAsync(); } } private sealed class TestDataScope(Guid managerId) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( managerId, "教务管理员", null, DataScope.All, new HashSet([SystemRoles.AcademicAdmin])); } private sealed class TestStudentDataScope(Guid studentUserId) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( studentUserId, "张明", null, DataScope.Self, new HashSet([SystemRoles.Student])); } }