using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class ContentManagementEndpointTests { [Fact] public async Task Tenant_content_management_requires_admin_authentication() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); using var response = await client.GetAsync("/api/tenant-content/entries"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Tenant_admin_can_upsert_entries_and_nodes() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var entryResponse = await client.PostAsJsonAsync( "/api/tenant-content/entries", new UpsertContentEntryDto { EntryKey = "exam-practice", Name = "刷题入口", EntryType = "questionPractice", Visibility = "members", Order = 10 }); var entryJson = await ReadJsonAsync(entryResponse); var entryId = entryJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); using var nodeResponse = await client.PostAsJsonAsync( "/api/tenant-content/nodes", new UpsertContentNodeDto { EntryId = entryId, NodeKey = "chapter-1", Name = "第一章", NodeType = "chapter", IsLeaf = true }); var nodeJson = await ReadJsonAsync(nodeResponse); using var listResponse = await client.GetAsync($"/api/tenant-content/nodes?entryId={entryId}&parentId=root"); var listJson = await ReadJsonAsync(listResponse); Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode); Assert.Equal("exam-practice", entryJson.RootElement.GetProperty("item").GetProperty("entryKey").GetString()); Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode); var node = nodeJson.RootElement.GetProperty("item"); Assert.Equal(0, node.GetProperty("depth").GetInt32()); Assert.StartsWith("n_", node.GetProperty("path").GetString(), StringComparison.Ordinal); Assert.Single(listJson.RootElement.GetProperty("items").EnumerateArray()); } [Fact] public async Task Tenant_admin_can_upsert_collection_replace_items_and_create_blueprint() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); var questionId = Guid.NewGuid(); await factory.SeedAsync(new Question { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var entryId = await CreateEntryAsync(client); var collectionResponse = await client.PostAsJsonAsync( "/api/tenant-content/question-collections", new UpsertQuestionCollectionDto { EntryId = entryId, Name = "基础题集", CollectionType = "manual", SourceType = "manualQuestions", TotalScore = 100, DurationMinutes = 45 }); var collectionJson = await ReadJsonAsync(collectionResponse); var collectionId = collectionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var replaceResponse = await client.PostAsJsonAsync( "/api/tenant-content/question-collections/items/replace", new ReplaceCollectionItemsDto { CollectionId = collectionId, Questions = [ new CollectionQuestionDto { QuestionId = questionId, SectionKey = "choice", Order = 1, Score = 5, Required = true } ] }); var replaceJson = await ReadJsonAsync(replaceResponse); var blueprintResponse = await client.PostAsJsonAsync( "/api/tenant-content/practice-blueprints", new UpsertPracticeBlueprintDto { EntryId = entryId, CollectionId = collectionId, Name = "章节练习", Mode = "sequential", AssemblyType = "collection", QuestionLimit = 10, TotalScore = 100 }); var blueprintJson = await ReadJsonAsync(blueprintResponse); Assert.Equal(HttpStatusCode.OK, collectionResponse.StatusCode); Assert.Equal("Manual", collectionJson.RootElement.GetProperty("item").GetProperty("collectionType").GetString()); Assert.Equal(HttpStatusCode.OK, replaceResponse.StatusCode); Assert.Equal(1, replaceJson.RootElement.GetProperty("questionCount").GetInt32()); Assert.Equal(HttpStatusCode.OK, blueprintResponse.StatusCode); Assert.Equal(collectionId, blueprintJson.RootElement.GetProperty("item").GetProperty("collectionId").GetGuid()); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount); Assert.Contains(dbContext.QuestionCollectionItems, item => item.QuestionId == questionId); } [Fact] public async Task Tenant_admin_can_get_import_field_mapping_and_templates() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var mappingResponse = await client.GetAsync("/api/tenant-content/imports/field-mapping?importType=questions"); using var templateResponse = await client.GetAsync("/api/tenant-content/imports/templates?importType=questions&format=csv"); var mapping = await ReadJsonAsync(mappingResponse); var template = await ReadJsonAsync(templateResponse); Assert.Equal(HttpStatusCode.OK, mappingResponse.StatusCode); Assert.Equal("questions", mapping.RootElement.GetProperty("importType").GetString()); Assert.Contains(mapping.RootElement.GetProperty("requiredFields").EnumerateArray(), item => item.GetString() == "type"); Assert.Equal(HttpStatusCode.OK, templateResponse.StatusCode); Assert.Equal("csv", template.RootElement.GetProperty("format").GetString()); Assert.NotEmpty(template.RootElement.GetProperty("contentBase64").GetString() ?? string.Empty); } private static async Task CreateEntryAsync(HttpClient client) { using var response = await client.PostAsJsonAsync( "/api/tenant-content/entries", new UpsertContentEntryDto { EntryKey = Guid.NewGuid().ToString("N"), Name = "刷题入口", EntryType = "questionPractice" }); var json = await ReadJsonAsync(response); return json.RootElement.GetProperty("item").GetProperty("id").GetGuid(); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13700000000"; var passwordHash = new PasswordHasher().Hash("passw0rd!"); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Test Tenant", Status = TenantStatus.Active, Metadata = JsonDefaults.Object() }, new User { Id = userId, Phone = phone, Name = "Tenant Admin" }, new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }, new UserIdentity { UserId = userId, Provider = "password", ProviderSubject = phone, Phone = phone, SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); } private static async Task LoginAsync( HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); } private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } private static JsonElement CreateSecretPayload(string passwordHash) { using var document = JsonDocument.Parse( $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } }