using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TelegramPanel.Core.Services; using TelegramPanel.Data; using TelegramPanel.Data.Entities; using TelegramPanel.Data.Repositories; using TelegramPanel.Modules; using TelegramPanel.Web.Modules; using TelegramPanel.Web.Services; using Xunit; namespace TelegramPanel.Web.Tests; public sealed class ModuleTaskLifecycleServiceTests { [Fact] public async Task Delete_calls_prepare_then_commit_after_host_row_is_removed() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); var task = await harness.Tasks.GetTaskAsync(harness.TaskId); await harness.Lifecycle.DeleteAsync(task!, "delete-operation"); Assert.Null(await harness.Tasks.GetTaskAsync(harness.TaskId)); Assert.Equal(new[] { "prepare:delete-operation", "commit:delete-operation" }, harness.Handler.Calls); } [Fact] public async Task Delete_calls_abort_and_keeps_host_row_when_host_delete_fails() { await using var harness = await CreateHarnessAsync(throwOnDelete: true); var task = await harness.Tasks.GetTaskAsync(harness.TaskId); var error = await Assert.ThrowsAsync( () => harness.Lifecycle.DeleteAsync(task!, "failed-delete")); Assert.Equal("模拟宿主删除失败", error.Message); Assert.NotNull(await harness.Tasks.GetTaskAsync(harness.TaskId)); Assert.Equal(new[] { "prepare:failed-delete", "abort:failed-delete" }, harness.Handler.Calls); } [Fact] public async Task Reconcile_passes_only_owned_task_ids_to_each_unique_handler() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); var otherOwnerTask = await harness.Tasks.CreateTaskAsync(new BatchTask { TaskType = "module.task", OwnerModuleId = "other.module", ExecutionKind = ModuleTaskExecutionKinds.Persistent, Total = 1 }); await harness.Lifecycle.ReconcileAsync(); Assert.Equal(new[] { harness.TaskId }, harness.Handler.ReconciledTaskIds); Assert.DoesNotContain(otherOwnerTask.Id, harness.Handler.ReconciledTaskIds); } [Fact] public async Task Delete_does_not_dispatch_old_owner_task_to_current_module_handler() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); var oldOwnerTask = await harness.Tasks.CreateTaskAsync(new BatchTask { TaskType = "module.task", OwnerModuleId = "old.module", ExecutionKind = ModuleTaskExecutionKinds.Persistent, Total = 1 }); await harness.Lifecycle.DeleteAsync(oldOwnerTask, "old-owner-delete"); Assert.Null(await harness.Tasks.GetTaskAsync(oldOwnerTask.Id)); Assert.Empty(harness.Handler.Calls); } [Fact] public async Task Validate_dispatches_owned_paused_draft_to_module_handler() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); var task = await harness.Tasks.GetTaskAsync(harness.TaskId); await harness.Lifecycle.ValidateAsync(task!, "edit-validation"); Assert.Equal(new[] { "validate:edit-validation" }, harness.Handler.Calls); } [Fact] public async Task Create_keeps_task_unclaimable_until_module_state_is_committed() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); var initializing = await harness.Tasks.CreateInitializingTaskAsync(new BatchTask { TaskType = "module.task", OwnerModuleId = "test.module", ExecutionKind = ModuleTaskExecutionKinds.Persistent, Total = 2, Config = "{\"version\":2}" }); Assert.Equal("initializing", initializing.Status); Assert.False(await harness.Tasks.TryStartTaskAsync(initializing.Id)); var activated = await harness.Lifecycle.CommitCreatedTaskAsync(initializing, "create-safe"); Assert.Equal("pending", activated.Status); Assert.Equal(new[] { "upsert:create-safe" }, harness.Handler.Calls); } [Fact] public async Task Create_commit_failure_marks_task_failed_instead_of_leaving_it_claimable() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); harness.Handler.CommitFailuresRemaining = 1; var initializing = await harness.Tasks.CreateInitializingTaskAsync(new BatchTask { TaskType = "module.task", OwnerModuleId = "test.module", ExecutionKind = ModuleTaskExecutionKinds.Persistent, Total = 1 }); await Assert.ThrowsAsync( () => harness.Lifecycle.CommitCreatedTaskAsync(initializing, "create-failed")); var failed = await harness.Tasks.GetTaskAsync(initializing.Id); Assert.Equal("failed", failed!.Status); Assert.Equal("initializing_failed", failed.RuntimePhase); Assert.True(failed.RequiresAttention); Assert.False(await harness.Tasks.TryStartTaskAsync(initializing.Id)); } [Fact] public async Task Edit_commit_failure_restores_previous_host_and_module_snapshot() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); var previous = await harness.Tasks.GetTaskAsync(harness.TaskId); harness.Handler.CommitFailuresRemaining = 1; Assert.True(await harness.Tasks.TryBeginEditableTaskUpdateAsync( harness.TaskId, 9, "{\"version\":9}", "新配置")); var updated = await harness.Tasks.GetTaskAsync(harness.TaskId); await Assert.ThrowsAsync( () => harness.Lifecycle.CommitEditedTaskAsync(updated!, previous!, "edit-failed")); var restored = await harness.Tasks.GetTaskAsync(harness.TaskId); Assert.Equal("paused", restored!.Status); Assert.Equal(previous!.Total, restored.Total); Assert.Equal(previous.Config, restored.Config); Assert.Equal(previous.Name, restored.Name); Assert.Equal("update_rolled_back", restored.RuntimePhase); Assert.True(restored.RequiresAttention); Assert.Equal( new[] { "upsert:edit-failed", "upsert:update:rollback:edit-failed" }, harness.Handler.Calls); } [Fact] public async Task Runtime_attention_is_persisted_on_host_task() { await using var harness = await CreateHarnessAsync(throwOnDelete: false); var heartbeat = DateTime.UtcNow; await harness.Tasks.UpdateTaskRuntimeStateAsync( harness.TaskId, "paused", "需要人工检查", heartbeat, true); var reloaded = await harness.Tasks.GetTaskAsync(harness.TaskId); Assert.Equal("paused", reloaded!.RuntimePhase); Assert.Equal("需要人工检查", reloaded.RuntimeMessage); Assert.Equal(heartbeat, reloaded.HeartbeatAtUtc); Assert.True(reloaded.RequiresAttention); } [Fact] public async Task Startup_recovery_is_shared_across_runners_and_executes_only_once() { await using var harness = await CreateHarnessAsync(throwOnDelete: false, initialStatus: "running"); await Task.WhenAll(Enumerable.Range(0, 8) .Select(_ => harness.StartupRecovery.EnsureRecoveredAsync(CancellationToken.None))); Assert.Equal("pending", (await harness.Tasks.GetTaskAsync(harness.TaskId))!.Status); Assert.Equal(1, harness.Handler.ReconcileCallCount); Assert.Equal(new[] { harness.TaskId }, harness.Handler.ReconciledTaskIds); } [Fact] public async Task Startup_recovery_confirms_interrupted_pausing_task_as_paused() { await using var harness = await CreateHarnessAsync(throwOnDelete: false, initialStatus: "pausing"); await harness.StartupRecovery.EnsureRecoveredAsync(CancellationToken.None); Assert.Equal("paused", (await harness.Tasks.GetTaskAsync(harness.TaskId))!.Status); Assert.Equal(1, harness.Handler.ReconcileCallCount); } [Fact] public async Task Startup_recovery_reconciles_then_activates_interrupted_initialization() { await using var harness = await CreateHarnessAsync(throwOnDelete: false, initialStatus: "initializing"); await harness.StartupRecovery.EnsureRecoveredAsync(CancellationToken.None); Assert.Equal("pending", (await harness.Tasks.GetTaskAsync(harness.TaskId))!.Status); Assert.Equal(1, harness.Handler.ReconcileCallCount); Assert.Contains($"upsert:recovery:create:{harness.TaskId}", harness.Handler.Calls); } [Fact] public async Task Startup_recovery_commits_interrupted_edit_before_returning_to_paused() { await using var harness = await CreateHarnessAsync(throwOnDelete: false, initialStatus: "updating"); await harness.StartupRecovery.EnsureRecoveredAsync(CancellationToken.None); Assert.Equal("paused", (await harness.Tasks.GetTaskAsync(harness.TaskId))!.Status); Assert.Contains($"upsert:update:recovery:{harness.TaskId}", harness.Handler.Calls); } private static async Task CreateHarnessAsync( bool throwOnDelete, string initialStatus = "paused") { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new ConfigurationBuilder().Build()); services.AddSingleton(connection); services.AddDbContext((provider, options) => options.UseSqlite(provider.GetRequiredService())); if (throwOnDelete) services.AddScoped(); else services.AddScoped(); services.AddScoped(); var handler = new RecordingLifecycleHandler("module.task"); services.AddSingleton(handler); var moduleRegistry = new ModuleRegistry(); var module = new TestTaskModule(); var moduleContext = new ModuleHostContext("1.0.0", Path.GetTempPath()); moduleRegistry.Add(new LoadedModule( module.Manifest.Id, module.Manifest.Version, false, module, moduleContext, module.Manifest, ModuleRootPath: null)); services.AddSingleton(moduleRegistry); services.AddSingleton(provider => new ModuleContributionRegistry( provider.GetRequiredService(), provider.GetRequiredService>(), provider)); services.AddSingleton(); services.AddSingleton(); var provider = services.BuildServiceProvider(); using (var scope = provider.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); db.BatchTasks.Add(new BatchTask { TaskType = "module.task", OwnerModuleId = "test.module", ExecutionKind = ModuleTaskExecutionKinds.Persistent, Status = initialStatus, Total = 1, CreatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); } var tasks = provider.GetRequiredService(); var taskId = (await tasks.GetAllTasksAsync()).Single().Id; return new TestHarness( connection, provider, tasks, provider.GetRequiredService(), provider.GetRequiredService(), handler, taskId); } private sealed class TestHarness : IAsyncDisposable { private readonly SqliteConnection _connection; private readonly ServiceProvider _provider; public TestHarness( SqliteConnection connection, ServiceProvider provider, BatchTaskManagementService tasks, ModuleTaskLifecycleService lifecycle, BatchTaskStartupRecoveryService startupRecovery, RecordingLifecycleHandler handler, int taskId) { _connection = connection; _provider = provider; Tasks = tasks; Lifecycle = lifecycle; StartupRecovery = startupRecovery; Handler = handler; TaskId = taskId; } public BatchTaskManagementService Tasks { get; } public ModuleTaskLifecycleService Lifecycle { get; } public BatchTaskStartupRecoveryService StartupRecovery { get; } public RecordingLifecycleHandler Handler { get; } public int TaskId { get; } public async ValueTask DisposeAsync() { await _provider.DisposeAsync(); await _connection.DisposeAsync(); } } private sealed class RecordingLifecycleHandler : IModuleTaskLifecycleHandler { public RecordingLifecycleHandler(string taskType) => TaskType = taskType; public string TaskType { get; } public List Calls { get; } = new(); public IReadOnlyCollection ReconciledTaskIds { get; private set; } = Array.Empty(); public int ReconcileCallCount { get; private set; } public int CommitFailuresRemaining { get; set; } public Task ValidateAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) { Calls.Add($"validate:{context.OperationId}"); return Task.CompletedTask; } public Task CommitUpsertAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) { Calls.Add($"upsert:{context.OperationId}"); if (CommitFailuresRemaining > 0) { CommitFailuresRemaining--; throw new InvalidOperationException("模拟模块状态提交失败"); } return Task.CompletedTask; } public Task PrepareDeleteAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) { Calls.Add($"prepare:{context.OperationId}"); return Task.CompletedTask; } public Task CommitDeleteAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) { Calls.Add($"commit:{context.OperationId}"); return Task.CompletedTask; } public Task AbortDeleteAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) { Calls.Add($"abort:{context.OperationId}"); return Task.CompletedTask; } public Task ReconcileAsync(IReadOnlyCollection existingTaskIds, CancellationToken cancellationToken = default) { ReconcileCallCount++; ReconciledTaskIds = existingTaskIds.ToArray(); return Task.CompletedTask; } } private sealed class TestTaskModule : ITelegramPanelModule, IModuleTaskProvider { public ModuleManifest Manifest { get; } = new() { Id = "test.module", Name = "生命周期测试模块", Version = "1.0.0" }; public void ConfigureServices(IServiceCollection services, ModuleHostContext context) { } public void MapEndpoints(Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, ModuleHostContext context) { } public IEnumerable GetTasks(ModuleHostContext context) { yield return new ModuleTaskDefinition { TaskType = "module.task", ExecutionKind = ModuleTaskExecutionKinds.Persistent, DisplayName = "生命周期测试任务", CreateRoute = "/ext/test.module/tasks" }; } } private sealed class ThrowingBatchTaskRepository : BatchTaskRepository { public ThrowingBatchTaskRepository(AppDbContext context) : base(context) { } public override Task DeleteAsync(BatchTask entity) => throw new InvalidOperationException("模拟宿主删除失败"); } }