using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class TenantAdminDirectEndpointTests { [Fact] public async Task Tenant_admin_can_manage_classes_members_and_students() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); var regionId = Guid.NewGuid(); await factory.SeedAsync(new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var classResponse = await client.PutAsJsonAsync( "/api/tenant-admin/classes", new UpsertTenantAdminClassDto { RegionId = regionId, Code = "A001", Name = "冲刺一班", Order = 1 }); var classJson = await ReadJsonAsync(classResponse); var classId = classJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var memberResponse = await client.PutAsJsonAsync( "/api/tenant-admin/classes/members", new UpsertTenantAdminClassMemberDto { ClassId = classId, MemberType = "student", User = new TenantAdminUserLookupDto { Phone = "13900000001", Name = "张同学" }, Metadata = JsonSerializer.SerializeToElement(new { source = "test" }) }); var memberJson = await ReadJsonAsync(memberResponse); var studentUserId = memberJson.RootElement.GetProperty("item").GetProperty("userId").GetGuid(); var listResponse = await client.GetAsync($"/api/tenant-admin/students?classId={classId}"); var listJson = await ReadJsonAsync(listResponse); var classesResponse = await client.GetAsync("/api/tenant-admin/classes"); var classesJson = await ReadJsonAsync(classesResponse); Assert.Equal(HttpStatusCode.OK, classResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); Assert.Equal(studentUserId, listJson.RootElement.GetProperty("items")[0].GetProperty("userId").GetGuid()); Assert.Equal(1, classesJson.RootElement.GetProperty("items")[0].GetProperty("studentCount").GetInt32()); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.True(await dbContext.StudentProfiles.AnyAsync(profile => profile.TenantId == seed.TenantId && profile.UserId == studentUserId)); Assert.True(await dbContext.TenantMemberships.AnyAsync(member => member.TenantId == seed.TenantId && member.UserId == studentUserId && member.Role == TenantRole.Student)); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.TenantId == seed.TenantId && log.Action == "tenant.class_member.upserted")); } [Fact] public async Task Tenant_admin_can_write_student_notes_and_followups() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); var studentId = Guid.NewGuid(); await factory.SeedAsync( new User { Id = studentId, Phone = "13900000002", Name = "李同学" }, new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var noteResponse = await client.PutAsJsonAsync( "/api/tenant-admin/student-notes", new UpsertTenantAdminStudentNoteDto { StudentUserId = studentId, NoteType = "learning", Content = "需要关注英语词汇", IsPinned = true }); var followupResponse = await client.PutAsJsonAsync( "/api/tenant-admin/student-followups", new UpsertTenantAdminStudentFollowupDto { StudentUserId = studentId, AssignedToUserId = seed.UserId, Title = "电话回访", FollowupType = "service", Priority = "high", Status = "in_progress" }); var notesResponse = await client.GetAsync($"/api/tenant-admin/student-notes?studentUserId={studentId}"); var followupsResponse = await client.GetAsync($"/api/tenant-admin/student-followups?studentUserId={studentId}"); var notesJson = await ReadJsonAsync(notesResponse); var followupsJson = await ReadJsonAsync(followupsResponse); Assert.Equal(HttpStatusCode.OK, noteResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, followupResponse.StatusCode); Assert.Single(notesJson.RootElement.GetProperty("items").EnumerateArray()); Assert.Single(followupsJson.RootElement.GetProperty("items").EnumerateArray()); Assert.Equal("电话回访", followupsJson.RootElement.GetProperty("items")[0].GetProperty("title").GetString()); } [Fact] public async Task Student_status_update_revokes_active_sessions() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); var studentId = Guid.NewGuid(); await factory.SeedAsync( new User { Id = studentId, Phone = "13900000003", Name = "王同学" }, new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, new AuthSession { TenantId = seed.TenantId, UserId = studentId, TokenHash = "student-session", Provider = "password", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var statusResponse = await client.PostAsJsonAsync( "/api/tenant-admin/students/status", new UpdateTenantAdminStudentStatusDto { UserId = studentId, Status = "disabled", Reason = "测试禁用" }); Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.True(await dbContext.AuthSessions.AnyAsync(session => session.UserId == studentId && session.RevokedAt != null)); } [Fact] public async Task Tenant_operator_cannot_access_tenant_admin_direct_endpoints() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory, TenantRole.TenantOperator); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var response = await client.GetAsync("/api/tenant-admin/students"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Tenant_admin_can_manage_branding_settings_theme_domains_and_auth_providers() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); await factory.SeedAsync(new TenantThemeTemplate { Code = "classic", Name = "经典主题", Theme = JsonSerializer.SerializeToElement(new { color = "blue" }), PublicAssets = JsonSerializer.SerializeToElement(new { logo = "/logo.png" }), Status = TenantThemeTemplateStatus.Active }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var brandingResponse = await client.PutAsJsonAsync( "/api/tenant-admin/branding", new UpsertTenantBrandingDto { BrandName = "机构题库", ShortName = "题库", ServiceWechat = "wechat" }); var settingsResponse = await client.PutAsJsonAsync( "/api/tenant-admin/settings", new UpsertTenantSettingsDto { FeatureFlags = JsonSerializer.SerializeToElement(new { catalog = true }), PublicConfig = JsonSerializer.SerializeToElement(new { icp = "蜀ICP备" }) }); var previewResponse = await client.PostAsJsonAsync( "/api/tenant-admin/theme/preview", new PreviewTenantThemeDto { TemplateCode = "classic", Theme = JsonSerializer.SerializeToElement(new { color = "red" }) }); var publishResponse = await client.PostAsJsonAsync( "/api/tenant-admin/theme/publish", new PublishTenantThemeDto { UseDraft = true }); var domainResponse = await client.PostAsJsonAsync( "/api/tenant-admin/domains", new CreateTenantDomainDto { Host = "learn.example.com", DomainType = "custom", IsPrimary = true }); var authProviderResponse = await client.PutAsJsonAsync( "/api/tenant-admin/auth-providers", new UpsertTenantAuthProviderDto { Provider = "wechat_mp", Status = "active", DisplayName = "微信公众号", ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-test" }) }); var secretSettingsResponse = await client.PutAsJsonAsync( "/api/tenant-admin/settings", new UpsertTenantSettingsDto { PublicConfig = JsonSerializer.SerializeToElement(new { appSecret = "nope" }) }); Assert.Equal(HttpStatusCode.OK, brandingResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, settingsResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, domainResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, authProviderResponse.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, secretSettingsResponse.StatusCode); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.True(await dbContext.TenantBrandings.AnyAsync(item => item.TenantId == seed.TenantId && item.BrandName == "机构题库")); Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic")); Assert.True(await dbContext.TenantDomains.AnyAsync(item => item.TenantId == seed.TenantId && item.Host == "learn.example.com" && item.IsPrimary)); Assert.True(await dbContext.TenantAuthProviders.AnyAsync(item => item.TenantId == seed.TenantId && item.Provider == "wechat_mp")); } [Fact] public async Task Tenant_admin_can_manage_role_templates_members_and_audit_logs() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); var roleResponse = await client.PutAsJsonAsync( "/api/tenant-admin/role-templates", new UpsertTenantAdminRoleTemplateDto { Code = "teacher-basic", Name = "教师基础权限", BaseRole = "teacher", Permissions = JsonSerializer.SerializeToElement(new Dictionary { ["classes:read"] = true, ["students:read"] = true }), FieldPermissions = JsonSerializer.SerializeToElement(new Dictionary { ["student.phone"] = false }) }); var roleJson = await ReadJsonAsync(roleResponse); var roleTemplateId = roleJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var memberResponse = await client.PutAsJsonAsync( "/api/tenant-admin/members", new UpsertTenantAdminMemberDto { RoleTemplateId = roleTemplateId, User = new TenantAdminUserLookupDto { Phone = "13900000004", Name = "教师" } }); var membersResponse = await client.GetAsync("/api/tenant-admin/members?role=teacher"); var permissionsResponse = await client.GetAsync("/api/tenant-admin/permissions"); var auditResponse = await client.GetAsync("/api/tenant-admin/audit-logs?action=tenant.role_template"); var membersJson = await ReadJsonAsync(membersResponse); var auditJson = await ReadJsonAsync(auditResponse); Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, membersResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, permissionsResponse.StatusCode); Assert.Single(membersJson.RootElement.GetProperty("items").EnumerateArray()); Assert.NotEmpty(auditJson.RootElement.GetProperty("items").EnumerateArray()); } [Fact] public async Task Tenant_admin_can_manage_badges_notifications_and_feedbacks() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); var studentId = Guid.NewGuid(); var feedbackId = Guid.NewGuid(); await factory.SeedAsync( new User { Id = studentId, Phone = "13900000005", Name = "反馈学生" }, new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, new StudentProfile { TenantId = seed.TenantId, UserId = studentId }, new Report { Id = feedbackId, TenantId = seed.TenantId, UserId = studentId, Type = ReportType.Suggestion, Title = "希望增加解析", Description = "视频解析再详细一点", Status = ReportStatus.Pending }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var badgeResponse = await client.PutAsJsonAsync( "/api/tenant-admin/badges", new UpsertTenantAdminBadgeDto { Name = "反馈达人", Category = "feedback", UnlockType = "manual", Order = 1 }); var badgeJson = await ReadJsonAsync(badgeResponse); var badgeId = badgeJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); var grantResponse = await client.PostAsJsonAsync( "/api/tenant-admin/badge-grants", new GrantTenantAdminBadgeDto { UserId = studentId, BadgeId = badgeId, Note = "感谢反馈" }); var notificationResponse = await client.PutAsJsonAsync( "/api/tenant-admin/notifications", new UpsertTenantAdminNotificationDto { UserId = studentId, NotificationType = "admin_message", Severity = "info", Title = "学习提醒", Message = "记得复习错题", DedupeKey = "daily-review" }); var notificationsListResponse = await client.GetAsync($"/api/tenant-admin/notifications?userId={studentId}"); var feedbackUpdateResponse = await client.PostAsJsonAsync( "/api/tenant-admin/feedbacks/status", new UpdateTenantAdminFeedbackDto { FeedbackId = feedbackId, Status = "resolved", Priority = "high", Resolution = "已安排补充解析", Note = "后台处理完成" }); var feedbackListResponse = await client.GetAsync("/api/tenant-admin/feedbacks?status=resolved"); var notificationsJson = await ReadJsonAsync(notificationsListResponse); var feedbackJson = await ReadJsonAsync(feedbackListResponse); Assert.Equal(HttpStatusCode.OK, badgeResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, grantResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, notificationResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, feedbackUpdateResponse.StatusCode); Assert.True(notificationsJson.RootElement.GetProperty("items").GetArrayLength() >= 2); Assert.Single(feedbackJson.RootElement.GetProperty("items").EnumerateArray()); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.True(await dbContext.UserBadges.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.BadgeId == badgeId)); Assert.True(await dbContext.UserNotifications.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.NotificationType == "badge_granted")); Assert.True(await dbContext.ReportStatusEvents.AnyAsync(item => item.TenantId == seed.TenantId && item.ReportId == feedbackId && item.ToStatus == ReportStatus.Resolved)); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync( ApiTestFactory factory, TenantRole role = TenantRole.TenantAdmin) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = $"137{Random.Shared.Next(10000000, 99999999)}"; 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 = role, 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(); } }