forked from gongxuegit/tiku-backend.net
feat: add tenant admin direct operations endpoints
This commit is contained in:
242
Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs
Normal file
242
Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
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.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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user