using System.Net; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class PlatformQuestionBankEndpointTests { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } }; [Fact] public async Task Platform_question_bank_workflow_is_scoped_versioned_and_idempotent() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" }); var platformTenantId = Guid.NewGuid(); var ordinaryTenantId = Guid.NewGuid(); var ordinaryBankId = Guid.NewGuid(); var platformUser = await SeedPlatformQuestionBankUserAsync(factory); await factory.SeedAsync( new Tenant { Id = platformTenantId, Slug = "platform-content-test", Name = "平台公共内容", Mode = TenantMode.PlatformOwned, Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, new Tenant { Id = ordinaryTenantId, Slug = "ordinary-question-bank", Name = "普通租户", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, new QuestionBank { Id = ordinaryBankId, TenantId = ordinaryTenantId, Name = "普通租户私有题库" }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platformUser.Email)); var bankResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks", new UpsertPlatformQuestionBankCommand(null, "平台高等数学公共题库", JsonDefaults.Object())); Assert.True(bankResponse.StatusCode == HttpStatusCode.OK, await bankResponse.Content.ReadAsStringAsync()); var bank = await bankResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(bank); Assert.NotNull(bank.ContentEntryId); var nodeResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/nodes", new UpsertPlatformQuestionBankNodeCommand( null, bank.Id, null, "math-chapter-1", "函数、极限与连续", ContentNodeType.Chapter, 10, true, JsonDefaults.Object())); Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode); var node = await nodeResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(node); var createQuestion = QuestionCommand(null, bank.Id, node.Id, "platform-question-1", "函数极限的定义是什么?"); var questionResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/questions", createQuestion); Assert.Equal(HttpStatusCode.OK, questionResponse.StatusCode); var question = await questionResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(question); Assert.Equal(1, question.VersionNo); var updatedResponse = await client.PutAsJsonAsync( "/api/platform-admin/question-banks/questions", createQuestion with { Id = question.Id, Content = "请说明函数极限的严格定义。" }); Assert.Equal(HttpStatusCode.OK, updatedResponse.StatusCode); var updated = await updatedResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(updated); Assert.Equal(2, updated.VersionNo); var import = new PlatformQuestionImportCommand( bank.Id, node.Id, "simple", "平台普通 JSON 回归", JsonSerializer.SerializeToElement(new object[] { new { legacyId = "import-stable-1", type = "true_false", content = "函数极限一定存在。", answerText = "错误" }, new { legacyId = "invalid-1", type = "choice", content = "" } })); var jobsBeforePreview = await CountImportJobsAsync(factory, platformTenantId); var previewResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports/preview", import); Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); var preview = await previewResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(preview); Assert.Equal(2, preview.Detail.Job.TotalCount); Assert.Equal(1, preview.Detail.Job.ValidCount); Assert.Equal(1, preview.Detail.Job.ErrorCount); Assert.Equal(jobsBeforePreview, await CountImportJobsAsync(factory, platformTenantId)); var firstImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import); var repeatedImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import); Assert.True(firstImportResponse.StatusCode == HttpStatusCode.OK, await firstImportResponse.Content.ReadAsStringAsync()); Assert.True(repeatedImportResponse.StatusCode == HttpStatusCode.OK, await repeatedImportResponse.Content.ReadAsStringAsync()); var firstImport = await firstImportResponse.Content.ReadFromJsonAsync(JsonOptions); var repeatedImport = await repeatedImportResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(firstImport); Assert.NotNull(repeatedImport); Assert.Equal(1, firstImport.InsertedQuestionCount); Assert.Equal(1, repeatedImport.SkippedQuestionCount); var structuredImport = new PlatformQuestionImportCommand( bank.Id, null, "structured-v2", "平台结构化 JSON 回归", JsonSerializer.SerializeToElement(new { _tikuExport = "2.0", subjects = new[] { new { code = "advanced-math", name = "高等数学", chapters = new[] { new { code = "limits", name = "极限", questions = new[] { new { legacyId = "structured-stable-1", type = "choice", content = "下列极限存在的是?", options = new[] { "A", "B" }, answerText = "A" } } } } } } })); var structuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport); var repeatedStructuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport); Assert.True(structuredResponse.StatusCode == HttpStatusCode.OK, await structuredResponse.Content.ReadAsStringAsync()); Assert.True(repeatedStructuredResponse.StatusCode == HttpStatusCode.OK, await repeatedStructuredResponse.Content.ReadAsStringAsync()); var structured = await structuredResponse.Content.ReadFromJsonAsync(JsonOptions); var repeatedStructured = await repeatedStructuredResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(structured); Assert.NotNull(repeatedStructured); Assert.Equal(2, structured.CreatedNodeCount); Assert.Equal(1, structured.InsertedQuestionCount); Assert.Equal(0, repeatedStructured.CreatedNodeCount); Assert.Equal(1, repeatedStructured.SkippedQuestionCount); var listedResponse = await client.GetAsync($"/api/platform-admin/question-banks/questions?questionBankId={bank.Id}&page=1&pageSize=20"); Assert.Equal(HttpStatusCode.OK, listedResponse.StatusCode); var listed = await listedResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(listed); Assert.Equal(3, listed.Total); var bankList = await client.GetFromJsonAsync("/api/platform-admin/question-banks?status=all", JsonOptions); Assert.NotNull(bankList); Assert.Contains(bankList, item => item.Id == bank.Id); Assert.DoesNotContain(bankList, item => item.Id == ordinaryBankId); using var scope = factory.CreateSystemScope("验证平台公共题库租户隔离与版本"); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Equal(platformTenantId, await dbContext.QuestionBanks.Where(item => item.Id == bank.Id).Select(item => item.TenantId).SingleAsync()); Assert.Equal(2, await dbContext.QuestionVersions.CountAsync(item => item.TenantId == platformTenantId && item.QuestionId == question.Id)); Assert.Equal(1, await dbContext.Questions.CountAsync(item => item.TenantId == platformTenantId && item.LegacyId == "import-stable-1")); } [Fact] public async Task Platform_question_bank_rejects_missing_permission_and_non_platform_host() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "platform.example.test" }); await factory.SeedAsync( new Tenant { Id = Guid.NewGuid(), Slug = "platform-content-auth", Name = "平台公共内容", Mode = TenantMode.PlatformOwned }); var platformWithoutPermission = await SeedPlatformUserAsync(factory, BackendPermissions.PlatformDashboardView); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platformWithoutPermission.Email)); using var wrongHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks"); wrongHostRequest.Headers.Host = "tenant.example.test"; var wrongHost = await client.SendAsync(wrongHostRequest); using var platformHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks"); platformHostRequest.Headers.Host = "platform.example.test"; var missingPermission = await client.SendAsync(platformHostRequest); Assert.Equal(HttpStatusCode.NotFound, wrongHost.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, missingPermission.StatusCode); } private static UpsertPlatformQuestionCommand QuestionCommand(Guid? id, Guid bankId, Guid nodeId, string legacyId, string content) => new( id, bankId, nodeId, legacyId, "short_answer", "简答题", 3, JsonSerializer.SerializeToElement(new[] { "极限" }), content, JsonDefaults.Array(), null, JsonDefaults.Array(), "按定义作答", "考查函数极限定义", JsonDefaults.Array(), null, null, null, "published", JsonDefaults.Object(), null); private static async Task CountImportJobsAsync(ApiTestFactory factory, Guid tenantId) { using var scope = factory.CreateSystemScope("统计平台公共题库导入任务"); return await scope.ServiceProvider.GetRequiredService().ContentImportJobs.CountAsync(item => item.TenantId == tenantId); } private static Task<(Guid UserId, string Email)> SeedPlatformQuestionBankUserAsync(ApiTestFactory factory) => SeedPlatformUserAsync(factory, BackendPermissions.PlatformQuestionBankManage); private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(ApiTestFactory factory, string permissionCode) { var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); var email = $"platform-question-bank-{Guid.NewGuid():N}@example.test"; var moduleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode); await factory.SeedAsync( new PermissionModule { Code = moduleCode, Name = "平台公共题库", Area = BackendPermissionArea.Platform }, new BackendPermission { Code = permissionCode, Name = "平台公共题库运营", Area = BackendPermissionArea.Platform, PermissionModuleCode = moduleCode, IsSystem = true }, new User { Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, NormalizedUserName = email.ToUpperInvariant(), Name = "平台题库运营", PrimaryRole = "platform_admin", RawProfile = JsonDefaults.Object() }.WithTestPassword(), new PlatformBackendRole { Id = roleId, Code = $"platform_question_bank_{roleId:N}", Name = "平台题库运营", Status = BackendRoleStatus.Active }, new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = permissionCode }, new PlatformBackendUserRole { UserId = userId, RoleId = roleId }); return (userId, email); } }