using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; 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.CreateSystemScope(); 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); } [Fact] public async Task ContentEntries_ApplySelfAndRestrictedScopesAndHideUnauthorizedUpdates() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); var allowedRegionId = Guid.NewGuid(); var outsideRegionId = Guid.NewGuid(); var regionalCreatorId = Guid.NewGuid(); var outsideCreatorId = Guid.NewGuid(); var ownEntry = new ContentEntry { TenantId = seed.TenantId, RegionId = outsideRegionId, EntryKey = "own-entry", Name = "Own Entry", CreatedBy = seed.UserId }; var regionalEntry = new ContentEntry { TenantId = seed.TenantId, RegionId = allowedRegionId, EntryKey = "regional-entry", Name = "Regional Entry", CreatedBy = regionalCreatorId }; var outsideEntry = new ContentEntry { TenantId = seed.TenantId, RegionId = outsideRegionId, EntryKey = "outside-entry", Name = "Outside Entry", CreatedBy = outsideCreatorId }; await factory.SeedAsync( new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" }, new Region { Id = outsideRegionId, TenantId = seed.TenantId, Name = "Outside Region" }, new User { Id = regionalCreatorId, Name = "Regional Creator" }, new User { Id = outsideCreatorId, Name = "Outside Creator" }, ownEntry, regionalEntry, outsideEntry); await SetDataScopeAsync(factory, seed.TenantId, new { mode = "self" }); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var selfResponse = await client.GetAsync("/api/tenant/content/entries?includeInactive=true"); using var selfJson = await ReadJsonAsync(selfResponse); await SetDataScopeAsync(factory, seed.TenantId, new { mode = "restricted", regionIds = new[] { allowedRegionId }, includesSelf = false }); using var restrictedResponse = await client.GetAsync("/api/tenant/content/entries?includeInactive=true"); using var restrictedJson = await ReadJsonAsync(restrictedResponse); using var deniedUpdate = await client.PostAsJsonAsync( "/api/tenant/content/entries", new UpsertContentEntryDto { Id = outsideEntry.Id, RegionId = outsideRegionId, EntryKey = outsideEntry.EntryKey, Name = "Must stay hidden" }); Assert.Equal([ownEntry.Id], selfJson.RootElement.GetProperty("items").EnumerateArray() .Select(item => item.GetProperty("id").GetGuid())); Assert.Equal([regionalEntry.Id], restrictedJson.RootElement.GetProperty("items").EnumerateArray() .Select(item => item.GetProperty("id").GetGuid())); Assert.Equal(HttpStatusCode.NotFound, deniedUpdate.StatusCode); } 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"; 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" }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); return (tenantId, userId, phone); } private static async Task LoginAsync( HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task SetDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value) { using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var role = dbContext.TenantBackendRoles.Single(item => item.TenantId == tenantId && item.Code == "integration_test_admin"); role.DataScope = JsonSerializer.SerializeToElement(value); await dbContext.SaveChangesAsync(); } private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } }