using Jiaowu.Api.Infrastructure.Experiments; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using MySql.Data.MySqlClient; namespace Jiaowu.Api.Tests; public sealed class MySqlMigrationTests { private const string LatestMigration = "20260727133346_ExamArrangementJobs"; [Fact] public void Production_migration_is_discoverable_and_generates_mysql_sql() { var options = CreateMySqlOptions(); using var db = new AppDbContext(options); Assert.Contains(LatestMigration, db.Database.GetMigrations()); var script = db.GetService().GenerateScript( fromMigration: "20260725040310_SchedulePublishJobs", toMigration: LatestMigration); Assert.Contains("CREATE TABLE `GradeItems`", script); Assert.Contains("CREATE TABLE `EvaluationSetups`", script); Assert.Contains("CREATE TABLE `WarningRules`", script); Assert.Contains("ADD `CheckInMethod` int NOT NULL DEFAULT 1", script); Assert.Contains("CREATE UNIQUE INDEX `IX_AttendanceSheets_CheckInToken`", script); Assert.Contains("ADD COLUMN `WaitlistedAt` datetime(6) NULL", script); Assert.Contains( "CREATE INDEX `IX_CE_Offering_Status_WaitlistedAt`", script); Assert.Contains("@waitlisted_column_exists", script); Assert.Contains("@waitlist_index_exists", script); Assert.DoesNotContain( "IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt", script); Assert.Contains( "ADD `MaxCourseCount` int NULL", script); Assert.Contains( "CREATE TABLE `CourseSelectionRoundGrades`", script); Assert.Contains( "CREATE UNIQUE INDEX `IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade`", script); Assert.Contains( "ADD `CalendarSubscriptionStamp` varchar(64) NULL", script); Assert.Contains( "ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL", script); Assert.Contains("CREATE TABLE `ExamRooms`", script); Assert.Contains("CREATE TABLE `ExamRoomSessions`", script); Assert.Contains("CREATE TABLE `ExamSeats`", script); Assert.Contains("CREATE TABLE `ExamRoomInvigilators`", script); Assert.Contains("CREATE TABLE `ExamArrangementJobs`", script); Assert.Contains( "CREATE UNIQUE INDEX `UX_ExamArrangementJobs_Kind_ActivePlan`", script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("DEFAULT 1", script); Assert.DoesNotContain("0001-01-01", script); Assert.DoesNotContain("0000-00-00", script); } [Fact] public void MySql_date_properties_use_datetime_provider_conversion() { using var db = new AppDbContext(CreateMySqlOptions()); var dateProperties = db.Model.GetEntityTypes() .SelectMany(x => x.GetProperties()) .Where(x => x.ClrType == typeof(DateOnly) || x.ClrType == typeof(DateOnly?)) .ToList(); Assert.NotEmpty(dateProperties); Assert.All(dateProperties, property => { Assert.Equal("date", property.GetColumnType()); Assert.Equal( typeof(DateTime), property.GetTypeMapping().Converter?.ProviderClrType); }); } [Fact] public void MySql_guid_collections_use_provider_safe_predicates() { using var db = new AppDbContext(CreateMySqlOptions()); var ids = new[] { Guid.Parse("11111111-1111-1111-1111-111111111111"), Guid.Parse("22222222-2222-2222-2222-222222222222") }; var sql = db.Colleges .WhereIn(ids, x => x.Id) .ToQueryString(); Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(ids[0].ToString(), sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void MySql_attendance_device_hash_query_uses_provider_safe_predicates() { using var db = new AppDbContext(CreateMySqlOptions()); var deviceHashes = new[] { "device-hash-one", "device-hash-two" }; var sql = db.AttendanceCheckInAttempts .Where(x => x.IsSuccessful && x.CreatedAt >= DateTime.UtcNow.AddHours(-24) && x.DeviceIdentifierHash != null) .WhereIn(deviceHashes, x => x.DeviceIdentifierHash!) .GroupBy(x => x.DeviceIdentifierHash!) .Select(x => new { DeviceIdentifierHash = x.Key, StudentCount = x.Select(a => a.StudentId).Distinct().Count() }) .ToQueryString(); Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(deviceHashes[0], sql, StringComparison.Ordinal); Assert.Contains(deviceHashes[1], sql, StringComparison.Ordinal); } [Fact] public void MySql_invigilator_candidate_query_is_translatable_without_uuid_ordering() { using var db = new AppDbContext(CreateMySqlOptions()); var excludedTeacherId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var sql = InvigilatorCandidateQuery .Create(db, [excludedTeacherId]) .ToQueryString(); Assert.Contains("Teachers", sql, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("UUID(", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void MySql_teaching_task_roster_query_is_translatable() { using var db = new AppDbContext(CreateMySqlOptions()); var studentId = Guid.Parse("22222222-2222-2222-2222-222222222222"); var sql = TeachingTaskRosterQuery .ForTask( db, Guid.Parse("11111111-1111-1111-1111-111111111111")) .ToQueryString(); var taskIdsSql = TeachingTaskRosterQuery .TaskIdsForStudent(db, studentId) .ToQueryString(); Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains( "TeachingTaskClasses", taskIdsSql, StringComparison.OrdinalIgnoreCase); Assert.Contains( "CourseEnrollments", taskIdsSql, StringComparison.OrdinalIgnoreCase); } [Fact] public void MySql_centralized_experiment_conflict_queries_are_translatable() { using var db = new AppDbContext(CreateMySqlOptions()); var firstId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var secondId = Guid.Parse("22222222-2222-2222-2222-222222222222"); Guid[] relatedIds = [firstId, secondId]; var teacherTaskSql = CentralizedExperimentConflictQuery .TaskIdsForTeachers(db, relatedIds) .ToQueryString(); var classTaskSql = CentralizedExperimentConflictQuery .TaskIdsForClasses(db, relatedIds) .ToQueryString(); var scheduleSql = CentralizedExperimentConflictQuery .ScheduleEntries( db, relatedIds, firstId, 1, 2, 1, 2) .ToQueryString(); var experimentSql = CentralizedExperimentConflictQuery .ExperimentSessions( db, relatedIds, new DateOnly(2026, 8, 1), 1, 2) .ToQueryString(); Assert.Contains( "TeachingTaskTeachers", teacherTaskSql, StringComparison.OrdinalIgnoreCase); Assert.Contains( "TeachingTaskClasses", classTaskSql, StringComparison.OrdinalIgnoreCase); Assert.Contains( "ScheduleEntries", scheduleSql, StringComparison.OrdinalIgnoreCase); Assert.Contains( "ExperimentSessions", experimentSql, StringComparison.OrdinalIgnoreCase); Assert.Contains(" IN (", scheduleSql, StringComparison.OrdinalIgnoreCase); Assert.Contains( " IN (", experimentSql, StringComparison.OrdinalIgnoreCase); } [Fact] public void MySql_index_names_fit_the_server_identifier_limit() { using var db = new AppDbContext(CreateMySqlOptions()); var indexNames = db.Model.GetEntityTypes() .SelectMany(x => x.GetIndexes()) .Select(x => x.GetDatabaseName()) .Where(x => !string.IsNullOrEmpty(x)) .ToList(); Assert.NotEmpty(indexNames); Assert.All(indexNames, name => Assert.True( name!.Length <= 64, $"MySQL index name '{name}' has {name.Length} characters.")); } [Fact] public void Application_mysql_connections_allow_migration_user_variables() { var normalized = MySqlConnectionStringPolicy.ForApplication( "Server=localhost;Database=jiaowu;User=test;Password=test;"); var builder = new MySqlConnectionStringBuilder(normalized); Assert.True(builder.AllowUserVariables); } private static DbContextOptions CreateMySqlOptions() => new DbContextOptionsBuilder() .UseMySQL( "Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;") .Options; }