forked from xiongyuxing/tiku-backend.net
feat: add platform public question bank workspace
This commit is contained in:
@@ -16,8 +16,8 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthorizationManifestTests
|
||||
{
|
||||
private const int ExpectedActionCount = 382;
|
||||
private const string ExpectedSha256 = "a1ca69bc9cfa439d5df1a456e60ba2f6b1f1baf1cca22e5a28be04a774682bb1";
|
||||
private const int ExpectedActionCount = 397;
|
||||
private const string ExpectedSha256 = "fe0636f609e86c8c7540d84914f8254106d20194ee5bc715d616c5a4f84c7a94";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
|
||||
@@ -50,4 +50,68 @@ public sealed class PlatformAdminStaticEndpointTests
|
||||
Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/api/auth/mfa", authentication, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_admin_shell_covers_current_platform_capabilities_and_dto_fields()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var application = await client.GetStringAsync("/platform-admin/app.js");
|
||||
var api = await client.GetStringAsync("/platform-admin/platform-api.js");
|
||||
var contract = await client.GetStringAsync("/platform-admin/spec-contract.js");
|
||||
|
||||
Assert.Contains("租户域名与证书", application, StringComparison.Ordinal);
|
||||
Assert.Contains("SaaS 商品与版本", application, StringComparison.Ordinal);
|
||||
Assert.Contains("SaaS 订单与交易", application, StringComparison.Ordinal);
|
||||
Assert.Contains("平台角色与功能", application, StringComparison.Ordinal);
|
||||
Assert.Contains("function businessStatusLabel(value)", application, StringComparison.Ordinal);
|
||||
Assert.Contains("function backendStatusLabel()", application, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("业务功能名称和中文说明", application, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("中文说明", application, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("API 契约", application, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/backoffice/platform/bootstrap", contract, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/platform-admin/payment-settings/channels", contract, StringComparison.Ordinal);
|
||||
Assert.Contains("/api/platform-admin/tenant-capabilities/crm/logs", contract, StringComparison.Ordinal);
|
||||
Assert.Contains("userId: input.userId", api, StringComparison.Ordinal);
|
||||
Assert.Contains("roleIds: input.roleIds", api, StringComparison.Ordinal);
|
||||
Assert.Contains("resolutionNote: input.resolutionNote", api, StringComparison.Ordinal);
|
||||
Assert.Contains("platformPermissionLabels[item.code]", api, StringComparison.Ordinal);
|
||||
Assert.Contains("'platform:role:manage': '管理平台角色与权限'", api, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("authUserId: input.authUserId", api, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("platformPermissions:", api, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("staffId: input.staffId", api, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_question_bank_workspace_loads_cache_busted_scripts_and_real_api_bindings()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var shell = await client.GetStringAsync("/platform-admin/");
|
||||
var application = await client.GetStringAsync("/platform-admin/app.js");
|
||||
var api = await client.GetStringAsync("/platform-admin/platform-api.js");
|
||||
var contract = await client.GetStringAsync("/platform-admin/spec-contract.js");
|
||||
|
||||
Assert.Contains("app.js?v=20260730-question-bank1", shell, StringComparison.Ordinal);
|
||||
Assert.Contains("platform-api.js?v=20260730-question-bank1", shell, StringComparison.Ordinal);
|
||||
Assert.Contains("spec-contract.js?v=20260730-question-bank1", shell, StringComparison.Ordinal);
|
||||
Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertBank'", application, StringComparison.Ordinal);
|
||||
Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertNode'", application, StringComparison.Ordinal);
|
||||
Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertQuestion'", application, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_archiveQuestions", application, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_archiveNode", application, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_archiveBank", application, StringComparison.Ordinal);
|
||||
Assert.Contains("data-action=\"shift-bank-node\"", application, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_signAssetUpload", application, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_confirmAssetUpload", application, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_upsertBank(input)", api, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_upsertQuestion(input)", api, StringComparison.Ordinal);
|
||||
Assert.Contains("PlatformQuestionBanksController_executeImport(input)", api, StringComparison.Ordinal);
|
||||
Assert.Contains("['P-09', 'PlatformQuestionBanksController_upsertBank', 'PUT', '/api/platform-admin/question-banks']", contract, StringComparison.Ordinal);
|
||||
Assert.Contains("['P-09', 'PlatformQuestionBanksController_executeImport', 'POST', '/api/platform-admin/question-banks/imports']", contract, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("question_bank:grant", application, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("question_bank:ops", application, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
219
Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs
Normal file
219
Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
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<string, string?>
|
||||
{
|
||||
["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<PlatformQuestionBankItem>(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<PlatformQuestionBankNodeItem>(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<PlatformQuestionItem>(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<PlatformQuestionItem>(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<PlatformQuestionImportResult>(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<PlatformQuestionImportResult>(JsonOptions);
|
||||
var repeatedImport = await repeatedImportResponse.Content.ReadFromJsonAsync<PlatformQuestionImportResult>(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<PlatformQuestionImportResult>(JsonOptions);
|
||||
var repeatedStructured = await repeatedStructuredResponse.Content.ReadFromJsonAsync<PlatformQuestionImportResult>(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<PlatformQuestionPage>(JsonOptions);
|
||||
Assert.NotNull(listed);
|
||||
Assert.Equal(3, listed.Total);
|
||||
|
||||
var bankList = await client.GetFromJsonAsync<PlatformQuestionBankItem[]>("/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<TikuDbContext>();
|
||||
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<string, string?>
|
||||
{
|
||||
["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<int> CountImportJobsAsync(ApiTestFactory factory, Guid tenantId)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("统计平台公共题库导入任务");
|
||||
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>().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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user