Files
tiku-backend.net/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs
2026-07-26 19:15:39 +08:00

463 lines
20 KiB
C#

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<TikuDbContext>();
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<TikuDbContext>();
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<TikuDbContext>();
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<string, bool>
{
["classes:read"] = true,
["students:read"] = true
}),
FieldPermissions = JsonSerializer.SerializeToElement(new Dictionary<string, bool>
{
["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<TikuDbContext>();
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<JsonDocument> 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();
}
}