using System.Reflection; using Jiaowu.Api.Contracts; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.BackgroundJobs; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Operations; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Jiaowu.Api.Tests; public sealed class OperationsControllerTests { [Fact] public void Controller_is_restricted_to_super_admin() { var authorize = typeof(OperationsController) .GetCustomAttribute(); Assert.NotNull(authorize); Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles); } [Fact] public async Task Audit_and_failed_job_queries_return_operational_records() { var root = CreateTemporaryRoot(); try { await using var fixture = await OperationsFixture.CreateAsync(root); var term = new AcademicTerm { Code = "OPS-TERM", Name = "运维测试学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 15) }; var plan = new SchedulePlan { AcademicTerm = term, Name = "恢复演练排课方案", Version = "V1" }; var job = new AutomaticScheduleJob { SchedulePlan = plan, Status = AutomaticScheduleJobStatus.Failed, ErrorMessage = "排课求解器未找到可行解。", CompletedAt = DateTime.UtcNow }; var outbox = BackgroundJobOutboxMessage.Create( BackgroundJobKind.AutomaticSchedule, job.Id); outbox.State = BackgroundJobOutboxState.Completed; outbox.ProcessingAttempts = 3; fixture.Db.AuditLogs.Add(new AuditLog { UserName = "root", Method = "POST", Path = "/api/schedules", StatusCode = 500, IpAddress = "127.0.0.1" }); fixture.Db.AddRange(term, plan, job, outbox); await fixture.Db.SaveChangesAsync(); var auditAction = await fixture.Controller.GetAuditLogs( cancellationToken: CancellationToken.None); var auditResult = Assert.IsType(auditAction.Result); var auditPage = Assert.IsType>(auditResult.Value); Assert.Single(auditPage.Items); Assert.Equal(500, auditPage.Items.Single().StatusCode); var jobsAction = await fixture.Controller.GetFailedJobs( cancellationToken: CancellationToken.None); var jobsResult = Assert.IsType(jobsAction.Result); var jobsPage = Assert.IsType>(jobsResult.Value); var failed = Assert.Single(jobsPage.Items); Assert.Equal("AutomaticSchedule", failed.Kind); Assert.Equal(3, failed.ProcessingAttempts); Assert.Contains("可行解", failed.ErrorMessage); } finally { DeleteTemporaryRoot(root); } } [Fact] public async Task Sqlite_backup_can_be_verified_in_an_isolated_restore_drill() { var root = CreateTemporaryRoot(); try { await using var fixture = await OperationsFixture.CreateAsync(root); fixture.Db.AuditLogs.Add(new AuditLog { UserName = "backup-test", Method = "POST", Path = "/api/test", StatusCode = 204 }); await fixture.Db.SaveChangesAsync(); var artifact = await fixture.Backups.CreateAsync( "自动化测试", CancellationToken.None); var result = await fixture.Backups.RunRestoreDrillAsync( artifact.Id, CancellationToken.None); var listed = await fixture.Backups.ListAsync(CancellationToken.None); Assert.True(File.Exists(Path.Combine( root, "backups", artifact.FileName))); Assert.Equal(64, artifact.Sha256.Length); Assert.True(result.Succeeded, result.Detail); Assert.True(result.TableCount > 0); Assert.True(Assert.Single(listed).LastDrillSucceeded); } finally { DeleteTemporaryRoot(root); } } private static string CreateTemporaryRoot() { var root = Path.Combine( Path.GetTempPath(), $"jiaowu-operations-{Guid.NewGuid():N}"); Directory.CreateDirectory(root); return root; } private static void DeleteTemporaryRoot(string root) { var resolved = Path.GetFullPath(root); var temporary = Path.GetFullPath(Path.GetTempPath()); Assert.StartsWith(temporary, resolved, StringComparison.OrdinalIgnoreCase); if (Directory.Exists(resolved)) Directory.Delete(resolved, recursive: true); } private sealed class OperationsFixture : IAsyncDisposable { private readonly ServiceProvider provider; private OperationsFixture( ServiceProvider provider, AppDbContext db, OperationsController controller, DatabaseBackupService backups) { this.provider = provider; Db = db; Controller = controller; Backups = backups; } public AppDbContext Db { get; } public OperationsController Controller { get; } public DatabaseBackupService Backups { get; } public static async Task CreateAsync(string root) { var databasePath = Path.Combine(root, "source.sqlite"); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ConnectionStrings:SQLite"] = $"Data Source={databasePath};Pooling=False" }) .Build(); var databaseOptions = new DatabaseOptions { Provider = "SQLite" }; var cacheOptions = new AppCacheOptions(); var operationsOptions = new OperationsOptions { BackupDirectory = "backups" }; var dbContextOptions = new DbContextOptionsBuilder() .UseSqlite(configuration.GetConnectionString("SQLite")) .Options; var db = new AppDbContext(dbContextOptions); await db.Database.EnsureCreatedAsync(); var services = new ServiceCollection() .AddLogging() .BuildServiceProvider(); var logger = services.GetRequiredService< ILogger>(); var environment = new TestHostEnvironment { ContentRootPath = root }; var transport = new InMemoryBackgroundJobTransport(); var monitoring = new BackgroundJobMonitoringService(db); var health = new OperationalHealthService( db, services, transport, monitoring, databaseOptions, cacheOptions, configuration); var backups = new DatabaseBackupService( databaseOptions, operationsOptions, configuration, environment, logger); var controller = new OperationsController( db, health, backups, operationsOptions); return new OperationsFixture(services, db, controller, backups); } public async ValueTask DisposeAsync() { await Db.DisposeAsync(); await provider.DisposeAsync(); } } private sealed class TestHostEnvironment : IWebHostEnvironment { public string ApplicationName { get; set; } = "Jiaowu.Api.Tests"; public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); public string WebRootPath { get; set; } = ""; public string EnvironmentName { get; set; } = Environments.Development; public string ContentRootPath { get; set; } = ""; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } }