Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/AttendanceControllerTests.cs
T
2026-07-28 20:08:12 +08:00

424 lines
15 KiB
C#

using ClosedXML.Excel;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class AttendanceControllerTests
{
[Fact]
public async Task TaskStatistics_UsesSubmittedSheetsAndExportsAllDetails()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
var studentUserId = Guid.NewGuid();
var studentUser = new ApplicationUser
{
Id = studentUserId,
UserName = "202601001",
NormalizedUserName = "202601001",
DisplayName = "周同学"
};
var firstStudent = new Student
{
StudentNumber = "202601001",
Name = "周同学",
UserId = studentUserId,
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
};
var secondStudentUserId = Guid.NewGuid();
var secondStudentUser = new ApplicationUser
{
Id = secondStudentUserId,
UserName = "202601002",
NormalizedUserName = "202601002",
DisplayName = "吴同学"
};
var secondStudent = new Student
{
StudentNumber = "202601002",
Name = "吴同学",
UserId = secondStudentUserId,
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
};
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 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 task = new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published
};
db.AddRange(
studentUser,
secondStudentUser,
college,
major,
administrativeClass,
firstStudent,
secondStudent,
course,
term,
task);
await db.SaveChangesAsync();
db.AttendanceSheets.AddRange(
new AttendanceSheet
{
TeachingTaskId = task.Id,
Name = "第1周点名",
AttendanceDate = new DateTime(2026, 9, 3),
Status = AttendanceSheetStatus.Submitted,
Records =
[
new AttendanceRecord
{
StudentId = firstStudent.Id,
Status = AttendanceStatus.Present
},
new AttendanceRecord
{
StudentId = secondStudent.Id,
Status = AttendanceStatus.Absent
}
]
},
new AttendanceSheet
{
TeachingTaskId = task.Id,
Name = "第2周点名",
AttendanceDate = new DateTime(2026, 9, 10),
Status = AttendanceSheetStatus.Submitted,
Records =
[
new AttendanceRecord
{
StudentId = firstStudent.Id,
Status = AttendanceStatus.Late
},
new AttendanceRecord
{
StudentId = secondStudent.Id,
Status = AttendanceStatus.Excused
}
]
},
new AttendanceSheet
{
TeachingTaskId = task.Id,
Name = "未提交点名",
AttendanceDate = new DateTime(2026, 9, 17),
Status = AttendanceSheetStatus.Draft,
Records =
[
new AttendanceRecord
{
StudentId = firstStudent.Id,
Status = AttendanceStatus.Absent
}
]
});
await db.SaveChangesAsync();
var controller = new AttendanceController(db, new AllDataScope());
var result = await controller.GetTaskStatistics(
task.Id,
CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var statistics = Assert.IsType<AttendanceCourseStatistics>(ok.Value);
Assert.Equal(2, statistics.Summary.SubmittedSheetCount);
Assert.Equal(4, statistics.Summary.RecordCount);
Assert.Equal(3, statistics.Summary.RequiredCount);
Assert.Equal(66.7m, statistics.Summary.OverallAttendanceRate);
Assert.Equal(0, statistics.Summary.PerfectAttendanceCount);
Assert.Equal(2, statistics.Students.Count);
Assert.Equal(0m, statistics.Students[0].AttendanceRate);
Assert.Equal(100m, statistics.Students[1].AttendanceRate);
var exportResult = await controller.ExportTaskStatistics(
task.Id,
CancellationToken.None);
var file = Assert.IsType<FileContentResult>(exportResult);
using var workbook = new XLWorkbook(new MemoryStream(file.FileContents));
Assert.NotNull(workbook.Worksheet("课程概览"));
Assert.Equal(
3,
workbook.Worksheet("学生出勤明细").LastRowUsed()!.RowNumber());
Assert.Equal(
3,
workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber());
var now = DateTime.UtcNow;
var qrRecord = new AttendanceRecord
{
StudentId = firstStudent.Id,
Status = AttendanceStatus.Absent
};
var secondQrRecord = new AttendanceRecord
{
StudentId = secondStudent.Id,
Status = AttendanceStatus.Absent
};
var qrSheet = new AttendanceSheet
{
TeachingTaskId = task.Id,
Name = "课堂扫码签到",
AttendanceDate = now,
Status = AttendanceSheetStatus.Draft,
CheckInMethod = AttendanceCheckInMethod.QrCode,
CheckInToken = "TEST-QR-TOKEN",
CheckInStartsAt = now.AddMinutes(-1),
CheckInEndsAt = now.AddMinutes(10),
Records = [qrRecord, secondQrRecord]
};
db.AttendanceSheets.Add(qrSheet);
await db.SaveChangesAsync();
var qrChallengeResult = await controller.GetQrChallenge(
qrSheet.Id,
CancellationToken.None);
var qrChallengeOk = Assert.IsType<OkObjectResult>(qrChallengeResult);
var qrToken = Assert.IsType<string>(
qrChallengeOk.Value!.GetType().GetProperty("Token")!.GetValue(
qrChallengeOk.Value));
var studentController = new AttendanceController(
db,
new StudentDataScope(studentUserId));
var myRecordsResult = await studentController.GetMyRecords(
null,
CancellationToken.None);
var myRecordsOk = Assert.IsType<OkObjectResult>(myRecordsResult);
var myRecords = Assert
.IsAssignableFrom<System.Collections.IEnumerable>(myRecordsOk.Value)
.Cast<object>()
.ToList();
Assert.Equal(2, myRecords.Count);
Assert.All(myRecords, item =>
Assert.Equal(
task.Id,
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
var infoResult = await studentController.GetCheckInInfo(
qrToken,
CancellationToken.None);
Assert.IsType<OkObjectResult>(infoResult);
Assert.IsType<NotFoundResult>(
await studentController.GetCheckInInfo(
qrSheet.CheckInToken,
CancellationToken.None));
var qrCheckInResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
null,
qrToken,
null,
null,
null,
"test-device-1",
"android"),
CancellationToken.None);
Assert.IsType<OkObjectResult>(qrCheckInResult);
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
Assert.NotNull(qrRecord.CheckInAt);
Assert.Null(qrRecord.CheckInLatitude);
var qrAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
x => x.AttendanceSheetId == qrSheet.Id);
Assert.True(qrAttempt.IsSuccessful);
Assert.Equal("android", qrAttempt.DevicePlatform);
Assert.NotNull(qrAttempt.DeviceIdentifierHash);
var secondStudentController = new AttendanceController(
db,
new StudentDataScope(secondStudentUserId));
Assert.IsType<OkObjectResult>(
await secondStudentController.CheckIn(
new AttendanceCheckInRequest(
null,
qrToken,
null,
null,
null,
"test-device-1",
"android"),
CancellationToken.None));
var sharedDeviceAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
x => x.AttendanceSheetId == qrSheet.Id &&
x.StudentId == secondStudent.Id);
Assert.Contains(
"SharedDevice",
sharedDeviceAttempt.RiskFlags ?? string.Empty);
var sheetDetailResult = await controller.GetSheet(
qrSheet.Id,
CancellationToken.None);
var sheetDetailOk = Assert.IsType<OkObjectResult>(sheetDetailResult);
var sheetDetail = sheetDetailOk.Value!.GetType()
.GetProperty("Sheet")!
.GetValue(sheetDetailOk.Value)!;
var riskSummary = sheetDetail.GetType()
.GetProperty("RiskSummary")!
.GetValue(sheetDetail)!;
Assert.Equal(
2,
riskSummary.GetType()
.GetProperty("SharedDeviceStudentCount")!
.GetValue(riskSummary));
var locationRecord = new AttendanceRecord
{
StudentId = firstStudent.Id,
Status = AttendanceStatus.Absent
};
var locationSheet = new AttendanceSheet
{
TeachingTaskId = task.Id,
Name = "课堂定位签到",
AttendanceDate = now,
Status = AttendanceSheetStatus.Draft,
CheckInMethod = AttendanceCheckInMethod.Location,
CheckInStartsAt = now.AddMinutes(-1),
CheckInEndsAt = now.AddMinutes(10),
TargetLatitude = 39.9m,
TargetLongitude = 116.4m,
LocationRadiusMeters = 100,
Records = [locationRecord]
};
db.AttendanceSheets.Add(locationSheet);
await db.SaveChangesAsync();
var outsideResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.91m,
116.4m,
8),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(outsideResult);
Assert.Null(locationRecord.CheckInAt);
var inaccurateResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.9001m,
116.4m,
150),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(inaccurateResult);
Assert.Null(locationRecord.CheckInAt);
var missingAccuracyResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.9001m,
116.4m,
null),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(missingAccuracyResult);
Assert.Null(locationRecord.CheckInAt);
var locationFailures = await db.AttendanceCheckInAttempts
.Where(x =>
x.AttendanceSheetId == locationSheet.Id &&
!x.IsSuccessful)
.OrderBy(x => x.CreatedAt)
.ToListAsync();
Assert.Equal(3, locationFailures.Count);
Assert.Contains(
locationFailures,
x => (x.RiskFlags ?? string.Empty).Contains(
"RepeatedFailures",
StringComparison.Ordinal));
var nearbyResult = await studentController.CheckIn(
new AttendanceCheckInRequest(
locationSheet.Id,
null,
39.9001m,
116.4m,
8),
CancellationToken.None);
Assert.IsType<OkObjectResult>(nearbyResult);
Assert.Equal(AttendanceStatus.Present, locationRecord.Status);
Assert.Equal(
AttendanceCheckInMethod.Location,
locationRecord.CheckedInMethod);
Assert.InRange(locationRecord.CheckInDistanceMeters!.Value, 1, 100);
}
private sealed class AllDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"测试学生",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
}