Files
Telegram-Panel/tests/TelegramPanel.Web.Tests/ModuleTaskLifecycleServiceTests.cs

426 lines
17 KiB
C#

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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<TestHarness> CreateHarnessAsync(
bool throwOnDelete,
string initialStatus = "paused")
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton(connection);
services.AddDbContext<AppDbContext>((provider, options) =>
options.UseSqlite(provider.GetRequiredService<SqliteConnection>()));
if (throwOnDelete)
services.AddScoped<IBatchTaskRepository, ThrowingBatchTaskRepository>();
else
services.AddScoped<IBatchTaskRepository, BatchTaskRepository>();
services.AddScoped<BatchTaskManagementService>();
var handler = new RecordingLifecycleHandler("module.task");
services.AddSingleton<IModuleTaskLifecycleHandler>(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<ModuleRegistry>(),
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<ModuleContributionRegistry>>(),
provider));
services.AddSingleton<ModuleTaskLifecycleService>();
services.AddSingleton<BatchTaskStartupRecoveryService>();
var provider = services.BuildServiceProvider();
using (var scope = provider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<BatchTaskManagementService>();
var taskId = (await tasks.GetAllTasksAsync()).Single().Id;
return new TestHarness(
connection,
provider,
tasks,
provider.GetRequiredService<ModuleTaskLifecycleService>(),
provider.GetRequiredService<BatchTaskStartupRecoveryService>(),
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<string> Calls { get; } = new();
public IReadOnlyCollection<int> ReconciledTaskIds { get; private set; } = Array.Empty<int>();
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<int> 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<ModuleTaskDefinition> 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("模拟宿主删除失败");
}
}