Files
tiku-backend.net/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

296 lines
14 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.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/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/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/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/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/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/question-banks/imports", import);
var repeatedImportResponse = await client.PostAsJsonAsync("/api/platform/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/question-banks/imports", structuredImport);
var repeatedStructuredResponse =
await client.PostAsJsonAsync("/api/platform/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/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/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/question-banks");
wrongHostRequest.Headers.Host = "tenant.example.test";
var wrongHost = await client.SendAsync(wrongHostRequest);
using var platformHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform/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)
{
return new UpsertPlatformQuestionCommand(
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)
{
return 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);
}
}