forked from xiongyuxing/tiku-backend.net
220 lines
13 KiB
C#
220 lines
13 KiB
C#
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);
|
|
}
|
|
}
|