forked from gongxuegit/tiku-backend.net
feat: complete phase six backoffice operations
This commit is contained in:
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -17,6 +18,177 @@ 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-admin/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-admin/students/import/preview", importRequest);
|
||||
var importResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import", importRequest);
|
||||
var importJson = await ReadJsonAsync(importResponse);
|
||||
var importedUserId = await GetUserIdByPhoneAsync(factory, "13900007777");
|
||||
var assignResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-admin/students/bulk-assign-class",
|
||||
new TenantAdminBulkAssignClassDto { ClassId = classId, UserIds = [existingStudentId, importedUserId] });
|
||||
var statusResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-admin/students/bulk-status",
|
||||
new TenantAdminBulkStatusDto { UserIds = [importedUserId], Status = "disabled", Reason = "batch test" });
|
||||
var ruleResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/students/supervision/rules",
|
||||
new UpsertTenantSupervisionRuleDto
|
||||
{
|
||||
Code = "inactive",
|
||||
Title = "长时间未学习",
|
||||
DaysWithoutCheckIn = 3,
|
||||
MaxQuestionsAnsweredToday = 0
|
||||
});
|
||||
var rulesResponse = await client.GetAsync("/api/tenant-admin/students/supervision/rules");
|
||||
var previewRiskResponse = await client.GetAsync("/api/tenant-admin/students/supervision/preview");
|
||||
var generateResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-admin/students/supervision/generate",
|
||||
new TenantSupervisionGenerateDto { UserIds = [existingStudentId], AssignedToUserId = seed.UserId });
|
||||
var followupReportResponse = await client.GetAsync("/api/tenant-admin/student-followups/report");
|
||||
var feedbackReportResponse = await client.GetAsync("/api/tenant-admin/feedbacks/report");
|
||||
var pointRiskReportResponse = await client.GetAsync("/api/tenant-admin/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()
|
||||
{
|
||||
@@ -404,6 +576,16 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user