296 lines
14 KiB
C#
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);
|
|
}
|
|
} |