639 lines
28 KiB
C#
639 lines
28 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.Commerce;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
public sealed class TenantAdminDirectEndpointTests
|
|
{
|
|
[Fact]
|
|
public async Task Tenant_admin_can_view_operational_overview()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedAdminAsync(factory);
|
|
var studentId = Guid.NewGuid();
|
|
var classId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new User { Id = studentId, Phone = "13900009901", Name = "概览学生" },
|
|
new TenantMembership
|
|
{
|
|
TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student,
|
|
Status = MembershipStatus.Active
|
|
},
|
|
new StudentProfile { TenantId = seed.TenantId, UserId = studentId },
|
|
new TenantClass
|
|
{ Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active },
|
|
new TenantStudentFollowup
|
|
{
|
|
TenantId = seed.TenantId,
|
|
StudentUserId = studentId,
|
|
Title = "待跟进",
|
|
Status = StudentFollowupStatus.Open
|
|
},
|
|
new UserNotification
|
|
{
|
|
TenantId = seed.TenantId,
|
|
UserId = studentId,
|
|
NotificationType = "admin",
|
|
Title = "通知",
|
|
Message = "概览通知",
|
|
Status = NotificationStatus.Unread
|
|
},
|
|
new Order
|
|
{
|
|
TenantId = seed.TenantId,
|
|
UserId = studentId,
|
|
OrderNo = "OVERVIEW-ORDER",
|
|
Status = OrderStatus.Paid,
|
|
AmountCents = 2_000,
|
|
PaidAt = DateTimeOffset.UtcNow
|
|
});
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
var response = await client.GetAsync("/api/tenant/overview");
|
|
var body = await ReadJsonAsync(response);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(1, body.RootElement.GetProperty("studentCount").GetInt32());
|
|
Assert.Equal(1, body.RootElement.GetProperty("classCount").GetInt32());
|
|
Assert.Equal(1, body.RootElement.GetProperty("pendingFollowupCount").GetInt32());
|
|
Assert.Equal(1, body.RootElement.GetProperty("unreadNotificationCount").GetInt32());
|
|
Assert.Equal(1, body.RootElement.GetProperty("paidOrderCount").GetInt32());
|
|
Assert.Equal(2_000, body.RootElement.GetProperty("revenueCents").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_admin_can_run_student_bulk_operations_supervision_and_reports()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var seed = await SeedAdminAsync(factory);
|
|
var regionId = Guid.NewGuid();
|
|
var classId = Guid.NewGuid();
|
|
var pointTaskId = Guid.NewGuid();
|
|
var pointItemId = Guid.NewGuid();
|
|
var existingStudentId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new Region { Id = regionId, TenantId = seed.TenantId, Name = "批量区域" },
|
|
new TenantClass
|
|
{
|
|
Id = classId, TenantId = seed.TenantId, RegionId = regionId, Name = "批量班级",
|
|
Status = TenantRecordStatus.Active
|
|
},
|
|
new User { Id = existingStudentId, Phone = "13900008888", Name = "已有学生", Score = -10 },
|
|
new TenantMembership
|
|
{
|
|
TenantId = seed.TenantId, UserId = existingStudentId, Role = TenantRole.Student,
|
|
Status = MembershipStatus.Active
|
|
},
|
|
new StudentProfile
|
|
{
|
|
TenantId = seed.TenantId,
|
|
UserId = existingStudentId,
|
|
RegionId = regionId,
|
|
LastCheckInDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10)),
|
|
QuestionsAnsweredToday = 0
|
|
},
|
|
new Report
|
|
{
|
|
TenantId = seed.TenantId, UserId = existingStudentId, Status = ReportStatus.Pending,
|
|
Type = ReportType.Suggestion
|
|
},
|
|
new PointActivityTask
|
|
{ Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 },
|
|
new PointActivityClaim
|
|
{
|
|
TenantId = seed.TenantId,
|
|
TaskId = pointTaskId,
|
|
UserId = existingStudentId,
|
|
TaskKey = "bulk-risk",
|
|
Points = 1000,
|
|
Status = PointActivityClaimStatus.Claimed
|
|
},
|
|
new PointExchangeItem
|
|
{ Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 },
|
|
new PointExchangeOrder
|
|
{
|
|
TenantId = seed.TenantId,
|
|
ItemId = pointItemId,
|
|
UserId = existingStudentId,
|
|
OrderNo = "POINT-RISK-1",
|
|
ItemName = "风险兑换",
|
|
Status = PointExchangeOrderStatus.Cancelled,
|
|
PointsCost = 10
|
|
});
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
var importRequest = new TenantAdminStudentImportDto
|
|
{
|
|
Rows =
|
|
[
|
|
new TenantAdminStudentImportRowDto
|
|
{
|
|
User = new TenantAdminUserLookupDto { Phone = "13900007777", Name = "批量学生" },
|
|
RegionId = regionId,
|
|
ClassId = classId
|
|
},
|
|
new TenantAdminStudentImportRowDto()
|
|
]
|
|
};
|
|
|
|
var previewResponse = await client.PostAsJsonAsync("/api/tenant/students/import/preview", importRequest);
|
|
var importResponse = await client.PostAsJsonAsync("/api/tenant/students/import", importRequest);
|
|
var importJson = await ReadJsonAsync(importResponse);
|
|
var importedUserId = await GetUserIdByPhoneAsync(factory, "13900007777");
|
|
var assignResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant/students/bulk-assign-class",
|
|
new TenantAdminBulkAssignClassDto { ClassId = classId, UserIds = [existingStudentId, importedUserId] });
|
|
var statusResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant/students/bulk-status",
|
|
new TenantAdminBulkStatusDto { UserIds = [importedUserId], Status = "disabled", Reason = "batch test" });
|
|
var ruleResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant/students/supervision/rules",
|
|
new UpsertTenantSupervisionRuleDto
|
|
{
|
|
Code = "inactive",
|
|
Title = "长时间未学习",
|
|
DaysWithoutCheckIn = 3,
|
|
MaxQuestionsAnsweredToday = 0
|
|
});
|
|
var rulesResponse = await client.GetAsync("/api/tenant/students/supervision/rules");
|
|
var previewRiskResponse = await client.GetAsync("/api/tenant/students/supervision/preview");
|
|
var generateResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant/students/supervision/generate",
|
|
new TenantSupervisionGenerateDto { UserIds = [existingStudentId], AssignedToUserId = seed.UserId });
|
|
var followupReportResponse = await client.GetAsync("/api/tenant/student-followups/report");
|
|
var feedbackReportResponse = await client.GetAsync("/api/tenant/feedbacks/report");
|
|
var pointRiskReportResponse = await client.GetAsync("/api/tenant/points/risk-report");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
|
|
Assert.Equal(1, importJson.RootElement.GetProperty("createdOrUpdatedCount").GetInt32());
|
|
Assert.Equal(1, importJson.RootElement.GetProperty("invalidItems").GetArrayLength());
|
|
Assert.Equal(HttpStatusCode.OK, assignResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, ruleResponse.StatusCode);
|
|
Assert.Contains("inactive", await rulesResponse.Content.ReadAsStringAsync(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains(existingStudentId.ToString(), await previewRiskResponse.Content.ReadAsStringAsync(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Assert.Equal(HttpStatusCode.OK, generateResponse.StatusCode);
|
|
Assert.Contains("openCount", await followupReportResponse.Content.ReadAsStringAsync(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("pendingCount", await feedbackReportResponse.Content.ReadAsStringAsync(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("negativeScoreUserCount", await pointRiskReportResponse.Content.ReadAsStringAsync(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
using var scope = factory.CreateSystemScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
Assert.True(await dbContext.TenantClassMembers.AnyAsync(item =>
|
|
item.TenantId == seed.TenantId &&
|
|
item.ClassId == classId &&
|
|
item.UserId == importedUserId));
|
|
Assert.True(await dbContext.TenantMemberships.AnyAsync(item =>
|
|
item.TenantId == seed.TenantId &&
|
|
item.UserId == importedUserId &&
|
|
item.Status == MembershipStatus.Disabled));
|
|
Assert.True(await dbContext.TenantStudentFollowups.AnyAsync(item =>
|
|
item.TenantId == seed.TenantId &&
|
|
item.StudentUserId == existingStudentId &&
|
|
item.FollowupType == StudentFollowupType.Risk));
|
|
}
|
|
|
|
[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/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/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/students?classId={classId}");
|
|
var listJson = await ReadJsonAsync(listResponse);
|
|
var classesResponse = await client.GetAsync("/api/tenant/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.CreateSystemScope();
|
|
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/student-notes",
|
|
new UpsertTenantAdminStudentNoteDto
|
|
{
|
|
StudentUserId = studentId,
|
|
NoteType = "learning",
|
|
Content = "需要关注英语词汇",
|
|
IsPinned = true
|
|
});
|
|
var followupResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant/student-followups",
|
|
new UpsertTenantAdminStudentFollowupDto
|
|
{
|
|
StudentUserId = studentId,
|
|
AssignedToUserId = seed.UserId,
|
|
Title = "电话回访",
|
|
FollowupType = "service",
|
|
Priority = "high",
|
|
Status = "in_progress"
|
|
});
|
|
var notesResponse = await client.GetAsync($"/api/tenant/student-notes?studentUserId={studentId}");
|
|
var followupsResponse = await client.GetAsync($"/api/tenant/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/students/status",
|
|
new UpdateTenantAdminStudentStatusDto
|
|
{
|
|
UserId = studentId,
|
|
Status = "disabled",
|
|
Reason = "测试禁用"
|
|
});
|
|
|
|
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
|
using var scope = factory.CreateSystemScope();
|
|
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/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/branding",
|
|
new UpsertTenantBrandingDto
|
|
{
|
|
BrandName = "机构题库",
|
|
ShortName = "题库",
|
|
ServiceWechat = "wechat"
|
|
});
|
|
var settingsResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant/settings",
|
|
new UpsertTenantSettingsDto
|
|
{
|
|
FeatureFlags = JsonSerializer.SerializeToElement(new { catalog = true }),
|
|
PublicConfig = JsonSerializer.SerializeToElement(new { icp = "蜀ICP备" })
|
|
});
|
|
var previewResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant/theme/preview",
|
|
new PreviewTenantThemeDto
|
|
{
|
|
TemplateCode = "classic",
|
|
Theme = JsonSerializer.SerializeToElement(new { color = "red" })
|
|
});
|
|
var publishResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant/theme/publish",
|
|
new PublishTenantThemeDto { UseDraft = true });
|
|
var domainResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant/domains",
|
|
new CreateTenantDomainDto
|
|
{
|
|
Host = "learn.example.com",
|
|
DomainType = "custom",
|
|
IsPrimary = true
|
|
});
|
|
var authProviderResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant/auth-providers",
|
|
new UpsertTenantIdentityProviderDto
|
|
{
|
|
Provider = "wechat_mp",
|
|
Status = "active",
|
|
DisplayName = "微信公众号",
|
|
ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-test" })
|
|
});
|
|
var secretSettingsResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant/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.CreateSystemScope();
|
|
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.TenantExternalProviders.AnyAsync(item =>
|
|
item.TenantId == seed.TenantId &&
|
|
item.Capability == TenantExternalProviderCapability.Identity &&
|
|
item.Provider == "wechat_mp"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_admin_can_manage_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 memberResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant/members",
|
|
new UpsertTenantAdminMemberDto
|
|
{
|
|
Role = "teacher",
|
|
User = new TenantAdminUserLookupDto
|
|
{
|
|
Phone = "13900000004",
|
|
Name = "教师"
|
|
}
|
|
});
|
|
var membersResponse = await client.GetAsync("/api/tenant/members?role=teacher");
|
|
var auditResponse = await client.GetAsync("/api/tenant/audit-logs?action=tenant.member");
|
|
var membersJson = await ReadJsonAsync(membersResponse);
|
|
var auditJson = await ReadJsonAsync(auditResponse);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, membersResponse.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/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/badge-grants",
|
|
new GrantTenantAdminBadgeDto
|
|
{
|
|
UserId = studentId,
|
|
BadgeId = badgeId,
|
|
Note = "感谢反馈"
|
|
});
|
|
var notificationResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant/notifications",
|
|
new UpsertTenantAdminNotificationDto
|
|
{
|
|
UserId = studentId,
|
|
NotificationType = "admin_message",
|
|
Severity = "info",
|
|
Title = "学习提醒",
|
|
Message = "记得复习错题",
|
|
DedupeKey = "daily-review"
|
|
});
|
|
var notificationsListResponse = await client.GetAsync($"/api/tenant/notifications?userId={studentId}");
|
|
var feedbackUpdateResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant/feedbacks/status",
|
|
new UpdateTenantAdminFeedbackDto
|
|
{
|
|
FeedbackId = feedbackId,
|
|
Status = "resolved",
|
|
Priority = "high",
|
|
Resolution = "已安排补充解析",
|
|
Note = "后台处理完成"
|
|
});
|
|
var feedbackListResponse = await client.GetAsync("/api/tenant/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.CreateSystemScope();
|
|
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)}";
|
|
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 = role,
|
|
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<Guid> GetUserIdByPhoneAsync(ApiTestFactory factory, string phone)
|
|
{
|
|
using var scope = factory.CreateSystemScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
return await dbContext.Users
|
|
.Where(user => user.Phone == phone)
|
|
.Select(user => user.Id)
|
|
.SingleAsync();
|
|
}
|
|
|
|
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
|
{
|
|
var stream = await response.Content.ReadAsStreamAsync();
|
|
return await JsonDocument.ParseAsync(stream);
|
|
}
|
|
} |