using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Modules; using TelegramPanel.Web.Modules; using TelegramPanel.Web.Modules.BuiltIn; using Xunit; namespace TelegramPanel.Web.Tests; public sealed class ModuleTaskCreationCatalogTests { [Fact] public void ConfigureServicesAtomically_restores_full_descriptor_snapshot_after_failure() { var services = new ServiceCollection(); services.AddSingleton(); services.AddScoped(); var snapshot = services.ToArray(); Assert.Throws(() => ModuleBootstrapper.ConfigureServicesAtomically( new MutatingThrowModule(), services, new ModuleHostContext("1.0.0", Path.GetTempPath()))); Assert.Equal(snapshot.Length, services.Count); for (var index = 0; index < snapshot.Length; index++) Assert.Same(snapshot[index], services[index]); } [Fact] public void Registry_keeps_all_definitions_but_only_exposes_valid_editors_for_creation() { var validEditorType = typeof(ValidTaskEditor).AssemblyQualifiedName!; var module = new TestTaskModule( new ModuleTaskDefinition { Category = "user", TaskType = "editor-backed", DisplayName = "有效编辑器", EditorComponentType = validEditorType }, new ModuleTaskDefinition { Category = "user", TaskType = "route-only", DisplayName = "独立页面", CreateRoute = "/ext/test/settings" }, new ModuleTaskDefinition { Category = "user", TaskType = "metadata-only", DisplayName = "仅任务元数据" }, new ModuleTaskDefinition { Category = "user", TaskType = "not-a-component", DisplayName = "无效组件类型", EditorComponentType = typeof(string).AssemblyQualifiedName }, new ModuleTaskDefinition { Category = "user", TaskType = "invalid-contract", DisplayName = "缺少编辑器参数", EditorComponentType = typeof(EditorWithoutDraftChanged).AssemblyQualifiedName }, new ModuleTaskDefinition { Category = "user", TaskType = "route-and-editor", DisplayName = "独立页优先", CreateRoute = "/ext/test/settings", EditorComponentType = validEditorType }); var externalContributions = CreateContributions(module, builtIn: false); Assert.Equal(6, externalContributions.Tasks.Count); Assert.Equal(6, externalContributions.TaskTypeToDefinition.Count); Assert.Equal("/ext/test/settings", externalContributions.TaskTypeToDefinition["route-only"].Definition.CreateRoute); Assert.Empty(externalContributions.CreatableTasks); Assert.All(externalContributions.Tasks, task => Assert.False(task.CanCreate)); var builtInContributions = CreateContributions(module, builtIn: true); var creatable = Assert.Single(builtInContributions.CreatableTasks); Assert.Equal("editor-backed", creatable.Definition.TaskType); Assert.True(creatable.CanCreate); Assert.False(builtInContributions.TaskTypeToDefinition["route-only"].CanCreate); Assert.False(builtInContributions.TaskTypeToDefinition["metadata-only"].CanCreate); Assert.False(builtInContributions.TaskTypeToDefinition["not-a-component"].CanCreate); Assert.False(builtInContributions.TaskTypeToDefinition["invalid-contract"].CanCreate); Assert.False(builtInContributions.TaskTypeToDefinition["route-and-editor"].CanCreate); } [Fact] public void Built_in_catalog_keeps_context_created_tasks_out_of_generic_create_dialog() { var contributions = CreateContributions(new TaskCatalogModule("1.0.0"), builtIn: true); Assert.Contains(BatchTaskTypes.ChannelInviteUsers, contributions.TaskTypeToDefinition.Keys); Assert.Contains(BatchTaskTypes.BotSetAdmins, contributions.TaskTypeToDefinition.Keys); var creatableTypes = contributions.CreatableTasks .Select(x => x.Definition.TaskType) .ToHashSet(StringComparer.OrdinalIgnoreCase); Assert.Equal(4, creatableTypes.Count); Assert.Contains(BatchTaskTypes.UserChatActive, creatableTypes); Assert.Contains(BatchTaskTypes.ChannelGroupPrivateCreate, creatableTypes); Assert.Contains(BatchTaskTypes.ChannelGroupPublicize, creatableTypes); Assert.Contains(BatchTaskTypes.AutoChangeLoginEmail, creatableTypes); Assert.DoesNotContain(BatchTaskTypes.ChannelInviteUsers, creatableTypes); Assert.DoesNotContain(BatchTaskTypes.BotSetAdmins, creatableTypes); } [Theory] [InlineData(ModuleTaskExecutionKinds.Batch)] [InlineData(ModuleTaskExecutionKinds.Persistent)] public void External_task_is_creatable_only_with_owned_route_and_matching_contract(string executionKind) { const string taskType = "external-owned-route"; var module = new TestTaskModule(new ModuleTaskDefinition { Category = "user", TaskType = taskType, DisplayName = "外部模块任务", ExecutionKind = executionKind, CreateRoute = "/ext/test.task-catalog/tasks/create" }); var services = new ServiceCollection(); if (executionKind == ModuleTaskExecutionKinds.Persistent) services.AddSingleton(new TestPersistentHandler(taskType)); else services.AddSingleton(new TestBatchHandler(taskType)); services.AddSingleton(new TestLifecycleHandler(taskType)); using var provider = services.BuildServiceProvider(); var contributions = CreateContributions(module, builtIn: false, provider); var creatable = Assert.Single(contributions.CreatableTasks); Assert.Equal(taskType, creatable.Definition.TaskType); Assert.Equal(executionKind, creatable.Definition.ExecutionKind); Assert.True(creatable.CanCreate); } [Theory] [InlineData("https://example.com/ext/test.task-catalog/tasks")] [InlineData("/ext/another-module/tasks")] [InlineData("/ext/test.task-catalog/../another-module/tasks")] [InlineData("/ext/test.task-catalog\\tasks")] public void External_task_rejects_routes_outside_its_module(string createRoute) { const string taskType = "unsafe-route"; var module = new TestTaskModule(new ModuleTaskDefinition { TaskType = taskType, DisplayName = "不安全路由", CreateRoute = createRoute }); var services = new ServiceCollection(); services.AddSingleton(new TestBatchHandler(taskType)); services.AddSingleton(new TestLifecycleHandler(taskType)); using var provider = services.BuildServiceProvider(); var contributions = CreateContributions(module, builtIn: false, provider); Assert.False(Assert.Single(contributions.Tasks).CanCreate); Assert.Empty(contributions.CreatableTasks); Assert.Contains(contributions.Diagnostics, message => message.Contains("CreateRoute", StringComparison.Ordinal)); } [Theory] [InlineData(true, false)] [InlineData(false, true)] [InlineData(true, true)] public void External_task_requires_exactly_one_matching_executor_and_lifecycle( bool duplicateExecutor, bool duplicateLifecycle) { const string taskType = "ambiguous-contract"; var module = new TestTaskModule(new ModuleTaskDefinition { TaskType = taskType, DisplayName = "合同冲突", CreateRoute = "/ext/test.task-catalog/tasks/create" }); var services = new ServiceCollection(); services.AddSingleton(new TestBatchHandler(taskType)); if (duplicateExecutor) services.AddSingleton(new TestBatchHandler(taskType)); services.AddSingleton(new TestLifecycleHandler(taskType)); if (duplicateLifecycle) services.AddSingleton(new TestLifecycleHandler(taskType)); using var provider = services.BuildServiceProvider(); var contributions = CreateContributions(module, builtIn: false, provider); Assert.False(Assert.Single(contributions.Tasks).CanCreate); Assert.Contains(contributions.Diagnostics, message => message.Contains("需要唯一执行器和生命周期处理器", StringComparison.Ordinal)); } [Fact] public void Persistent_definition_does_not_accept_batch_executor() { const string taskType = "persistent-with-batch-handler"; var module = new TestTaskModule(new ModuleTaskDefinition { TaskType = taskType, DisplayName = "执行器类型不匹配", ExecutionKind = ModuleTaskExecutionKinds.Persistent, CreateRoute = "/ext/test.task-catalog/tasks/create" }); var services = new ServiceCollection(); services.AddSingleton(new TestBatchHandler(taskType)); services.AddSingleton(new TestLifecycleHandler(taskType)); using var provider = services.BuildServiceProvider(); var contributions = CreateContributions(module, builtIn: false, provider); Assert.False(Assert.Single(contributions.Tasks).CanCreate); Assert.Contains(contributions.Diagnostics, message => message.Contains("executor=0", StringComparison.Ordinal)); } [Fact] public void Conflicting_task_types_are_not_indexed_or_creatable() { const string taskType = "conflicting-task"; var first = new TestTaskModule(new ModuleTaskDefinition { TaskType = taskType, DisplayName = "第一个任务", CreateRoute = "/ext/test.task-catalog/tasks/create" }); var second = new AlternateTestTaskModule(new ModuleTaskDefinition { TaskType = taskType, DisplayName = "第二个任务", CreateRoute = "/ext/test.task-catalog-alternate/tasks/create" }); var registry = new ModuleRegistry(); AddModule(registry, first, builtIn: false); AddModule(registry, second, builtIn: false); var services = new ServiceCollection(); services.AddSingleton(new TestBatchHandler(taskType)); services.AddSingleton(new TestLifecycleHandler(taskType)); using var provider = services.BuildServiceProvider(); var contributions = new ModuleContributionRegistry( registry, NullLogger.Instance, provider); Assert.Equal(2, contributions.Tasks.Count); Assert.Empty(contributions.CreatableTasks); Assert.DoesNotContain(taskType, contributions.TaskTypeToDefinition.Keys); Assert.Contains(contributions.Diagnostics, message => message.Contains("任务类型冲突", StringComparison.Ordinal)); } private static ModuleContributionRegistry CreateContributions( ITelegramPanelModule module, bool builtIn, IServiceProvider? serviceProvider = null) { var registry = new ModuleRegistry(); AddModule(registry, module, builtIn); return new ModuleContributionRegistry( registry, NullLogger.Instance, serviceProvider); } private static void AddModule(ModuleRegistry registry, ITelegramPanelModule module, bool builtIn) { var context = new ModuleHostContext("1.0.0", Path.GetTempPath()); registry.Add(new LoadedModule( module.Manifest.Id, module.Manifest.Version, builtIn, module, context, module.Manifest, ModuleRootPath: null)); } public sealed class ValidTaskEditor : ComponentBase { [Parameter] public ModuleTaskDraft Draft { get; set; } [Parameter] public EventCallback DraftChanged { get; set; } } public sealed class EditorWithoutDraftChanged : ComponentBase { [Parameter] public ModuleTaskDraft Draft { get; set; } } private sealed class TestTaskModule : ITelegramPanelModule, IModuleTaskProvider { private readonly IReadOnlyList _definitions; public TestTaskModule(params ModuleTaskDefinition[] definitions) { _definitions = definitions; } public ModuleManifest Manifest { get; } = new() { Id = "test.task-catalog", Name = "任务目录测试模块", Version = "1.0.0" }; public void ConfigureServices(IServiceCollection services, ModuleHostContext context) { } public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) { } public IEnumerable GetTasks(ModuleHostContext context) => _definitions; } private sealed class AlternateTestTaskModule : ITelegramPanelModule, IModuleTaskProvider { private readonly IReadOnlyList _definitions; public AlternateTestTaskModule(params ModuleTaskDefinition[] definitions) { _definitions = definitions; } public ModuleManifest Manifest { get; } = new() { Id = "test.task-catalog-alternate", Name = "备用任务目录测试模块", Version = "1.0.0" }; public void ConfigureServices(IServiceCollection services, ModuleHostContext context) { } public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) { } public IEnumerable GetTasks(ModuleHostContext context) => _definitions; } private sealed class TestBatchHandler : IModuleTaskHandler { public TestBatchHandler(string taskType) => TaskType = taskType; public string TaskType { get; } public Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class TestPersistentHandler : IModulePersistentTaskHandler { public TestPersistentHandler(string taskType) => TaskType = taskType; public string TaskType { get; } public Task ExecuteAsync(IModulePersistentTaskExecutionHost host, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class TestLifecycleHandler : IModuleTaskLifecycleHandler { public TestLifecycleHandler(string taskType) => TaskType = taskType; public string TaskType { get; } public Task ValidateAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task PrepareDeleteAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task CommitDeleteAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task AbortDeleteAsync(ModuleTaskLifecycleContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task ReconcileAsync(IReadOnlyCollection existingTaskIds, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class MutatingThrowModule : ITelegramPanelModule { public ModuleManifest Manifest { get; } = new() { Id = "test.configure-rollback", Name = "服务回滚测试模块", Version = "1.0.0" }; public void ConfigureServices(IServiceCollection services, ModuleHostContext context) { services.RemoveAt(0); services.Insert(0, ServiceDescriptor.Singleton(typeof(TestServiceReplacement), typeof(TestServiceReplacement))); services.AddSingleton(); throw new InvalidOperationException("模拟模块注册失败"); } public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) { } } private sealed class TestServiceMarker { } private sealed class TestServiceReplacement { } }