feat: complete phase six backoffice operations

This commit is contained in:
2026-07-28 14:31:43 +08:00
parent 747ff59d76
commit 99e4e43122
31 changed files with 23504 additions and 26 deletions

View File

@@ -34,6 +34,24 @@ internal static class AuthenticationTestClientExtensions
return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier);
}
public static async Task<TestAuthenticationTokens> LoginAsPlatformAsync(
this HttpClient client,
string identifier,
string password = PasswordTestUserExtensions.TestPassword)
{
client.DefaultRequestHeaders.Remove("x-tenant-code");
var response = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
Realm = AuthRealm.Platform,
Identifier = identifier,
Password = password
});
return await client.CompletePlatformAuthenticationAsync(response, identifier);
}
public static async Task<TestAuthenticationTokens> CompleteTenantAuthenticationAsync(
this HttpClient client,
HttpResponseMessage response,
@@ -96,6 +114,67 @@ internal static class AuthenticationTestClientExtensions
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
}
private static async Task<TestAuthenticationTokens> CompletePlatformAuthenticationAsync(
this HttpClient client,
HttpResponseMessage response,
string authenticatorCacheKey)
{
client.DefaultRequestHeaders.Remove("x-tenant-code");
using var authentication = await ReadSuccessfulJsonAsync(response);
var root = authentication.RootElement;
var status = root.GetProperty("status").GetString();
if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase))
{
return ReadTokens(root.GetProperty("user").GetProperty("tokens"));
}
var challengeToken = root.GetProperty("challengeToken").GetString()
?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token.");
var keyId = $"platform:{authenticatorCacheKey}";
if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase))
{
var setupResponse = await client.PostAsJsonAsync(
"/api/auth/mfa/totp/setup",
new MfaChallengeDto { ChallengeToken = challengeToken });
using var setup = await ReadSuccessfulJsonAsync(setupResponse);
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()
?? throw new InvalidOperationException("MFA setup did not return a shared key.");
AuthenticatorKeys[keyId] = sharedKey;
var confirmResponse = await client.PostAsJsonAsync(
"/api/auth/mfa/totp/confirm",
new MfaChallengeDto
{
ChallengeToken = challengeToken,
Code = GenerateTotp(sharedKey)
});
using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse);
return ReadTokens(
confirmation.RootElement
.GetProperty("authentication")
.GetProperty("user")
.GetProperty("tokens"));
}
if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) &&
AuthenticatorKeys.TryGetValue(keyId, out var existingKey))
{
var verifyResponse = await client.PostAsJsonAsync(
"/api/auth/mfa/totp/verify",
new MfaChallengeDto
{
ChallengeToken = challengeToken,
Code = GenerateTotp(existingKey)
});
using var verification = await ReadSuccessfulJsonAsync(verifyResponse);
return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens"));
}
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
}
public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens)
{
client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken);

View File

@@ -0,0 +1,310 @@
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.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class PlatformAdminEndpointTests
{
[Fact]
public async Task Platform_admin_can_manage_tenant_subscription_domain_recheck_and_audit()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var platform = await SeedPlatformAdminAsync(factory);
var tenantId = Guid.NewGuid();
var domainId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = "tenant-six-a",
Name = "Tenant Six A",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Trial
},
new TenantDomain
{
Id = domainId,
TenantId = tenantId,
Host = "six-a.example.test",
Status = TenantDomainStatus.Active,
IsPrimary = true,
VerificationToken = "verify-six-a",
VerifiedAt = DateTimeOffset.UtcNow,
DnsVerifiedAt = DateTimeOffset.UtcNow,
TlsReadyAt = DateTimeOffset.UtcNow
},
new PlatformSaasPlan
{
Code = "standard",
Name = "Standard",
BaseAmountCents = 99900,
Status = PlatformSaasPlanStatus.Active
});
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
var overview = await client.GetAsync("/api/platform-admin/overview");
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
var subscription = await client.PostAsJsonAsync(
"/api/platform-admin/subscriptions",
new UpsertPlatformSubscriptionDto
{
TenantId = tenantId,
PlanCode = "standard",
Status = TenantSubscriptionStatus.Active,
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
AmountCents = 99900
});
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
var suspended = await client.PatchAsJsonAsync(
"/api/platform-admin/tenants/status",
new UpdatePlatformTenantStatusDto
{
TenantId = tenantId,
Status = TenantStatus.Suspended,
BillingStatus = BillingStatus.PastDue,
Reason = "integration test suspension"
});
using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
runtimeRequest.Headers.Host = "six-a.example.test";
var runtimeAfterSuspend = await client.SendAsync(runtimeRequest);
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
using var scope = factory.CreateSystemScope("Verify platform admin side effects");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.True(await dbContext.BackgroundJobs.AnyAsync(job =>
job.TenantId == tenantId &&
job.JobType == "tenant_domain_recheck"));
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.tenant.status_changed"));
}
[Fact]
public async Task Tenant_token_cannot_access_platform_admin_endpoints()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13866660000";
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Tenant Realm"
},
new User
{
Id = userId,
Phone = phone,
Name = "Tenant Admin"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
});
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
var response = await client.GetAsync("/api/platform-admin/overview");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Platform_admin_can_manage_dunning_channels_and_retry_events()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var platform = await SeedPlatformAdminAsync(factory);
var tenantId = Guid.NewGuid();
var invoiceId = Guid.NewGuid();
var reminderId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = "tenant-dunning-a",
Name = "Tenant Dunning A",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.PastDue
},
new TenantInvoice
{
Id = invoiceId,
TenantId = tenantId,
InvoiceNo = "INV-DUNNING-1",
Status = TenantInvoiceStatus.Overdue,
TotalCents = 10_000,
BalanceCents = 10_000
},
new TenantInvoiceReminder
{
Id = reminderId,
TenantId = tenantId,
InvoiceId = invoiceId,
ReminderType = TenantInvoiceReminderType.Overdue,
Channel = TenantInvoiceReminderChannel.Wechat,
Status = TenantInvoiceReminderStatus.Failed,
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
BalanceCentsSnapshot = 10_000
});
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
var upsertResponse = await client.PutAsJsonAsync(
"/api/platform-admin/dunning-notification-channels",
new UpsertPlatformDunningChannelDto
{
ChannelCode = "wecom-overdue",
Name = "企业微信逾期提醒",
Provider = PlatformDunningProvider.Wecom,
WebhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret-key",
SecretRef = "platform_secrets:dunning:wecom:default",
ReminderTypes = ["overdue"],
ReminderChannels = ["wechat"],
MinReminderLevel = 2,
TenantIds = [tenantId]
});
var upsertBody = await upsertResponse.Content.ReadAsStringAsync();
var channelJson = JsonDocument.Parse(upsertBody);
var channelId = channelJson.RootElement.GetProperty("id").GetGuid();
await factory.SeedAsync(new PlatformDunningNotificationEvent
{
TenantId = tenantId,
ChannelId = channelId,
ReminderId = reminderId,
InvoiceId = invoiceId,
Provider = PlatformDunningProvider.Wecom,
Status = PlatformDunningNotificationStatus.Failed,
Attempts = 2,
LastError = "timeout",
LastHttpCode = 500,
LastResponseSummary = "server error",
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
});
var channelsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-channels?search=wecom");
var eventsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-events?status=failed");
var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync());
var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid();
var detailResponse = await client.GetAsync($"/api/platform-admin/dunning-notification-events/detail?eventId={eventId}");
var retryResponse = await client.PostAsJsonAsync(
"/api/platform-admin/dunning-notification-events/retry",
new RetryPlatformDunningEventDto
{
EventId = eventId,
Reason = "manual retry"
});
var disableResponse = await client.PostAsJsonAsync(
"/api/platform-admin/dunning-notification-channels/disable",
new DisablePlatformDunningChannelDto
{
ChannelId = channelId,
Reason = "disable test"
});
Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode);
Assert.DoesNotContain("secret-key", upsertBody, StringComparison.OrdinalIgnoreCase);
Assert.Contains("https://qyapi.weixin.qq.com/****", upsertBody, StringComparison.OrdinalIgnoreCase);
Assert.Equal(HttpStatusCode.OK, channelsResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
using var scope = factory.CreateSystemScope("Verify platform dunning side effects");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedEvent = await dbContext.PlatformDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
var storedChannel = await dbContext.PlatformDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
Assert.Equal(PlatformDunningNotificationStatus.Pending, storedEvent.Status);
Assert.Null(storedEvent.LastError);
Assert.False(storedChannel.Enabled);
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.dunning_event.retry_requested"));
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.dunning_channel.disabled"));
}
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
{
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var email = $"platform-{Guid.NewGuid():N}@example.test";
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
Module = code.Split(':')[1],
IsSystem = true
}).Cast<object>().ToList();
await factory.SeedAsync(
[
..permissions,
new User
{
Id = userId,
Email = email,
NormalizedEmail = email.ToUpperInvariant(),
UserName = email,
NormalizedUserName = email.ToUpperInvariant(),
Name = "Platform Admin",
PrimaryRole = "platform_admin",
RawProfile = JsonDefaults.Object()
}.WithTestPassword(),
new PlatformBackendRole
{
Id = roleId,
Code = "platform_super_admin",
Name = "Platform Super Admin",
Status = BackendRoleStatus.Active,
IsSystem = true
},
..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission
{
RoleId = roleId,
PermissionCode = code
}),
new PlatformBackendUserRole
{
UserId = userId,
RoleId = roleId
}
]);
return (userId, email);
}
}

View File

@@ -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();

View File

@@ -1,14 +1,18 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Auth;
using Tiku.Application.Commerce;
using Tiku.Application.Jobs;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
@@ -17,6 +21,11 @@ namespace Tiku.IntegrationTests.Api;
public sealed class TenantCommerceEndpointTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
[Fact]
public async Task Non_admin_cannot_access_tenant_commerce_operations()
{
@@ -152,6 +161,89 @@ public sealed class TenantCommerceEndpointTests
item.SourceType == "activation_code");
}
[Fact]
public async Task Commerce_reconciliation_worker_does_not_succeed_without_active_provider_config()
{
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Worker Provider Required"
});
using var scope = factory.CreateSystemScope();
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
var job = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"commerce_reconciliation",
JsonSerializer.SerializeToElement(new
{
provider = PaymentProviders.WechatPay,
billDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
billType = ReconciliationBillType.Combined.ToString()
})));
var processed = await jobService.ProcessPendingAsync("integration-worker", 10);
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id);
Assert.Equal(1, processed);
Assert.Equal(BackgroundJobStatus.Pending, storedJob.Status);
Assert.Equal(1, storedJob.RetryCount);
Assert.Contains("Active payment provider", storedJob.LastError, StringComparison.OrdinalIgnoreCase);
Assert.False(await dbContext.CommerceReconciliationBatches.AnyAsync(item => item.TenantId == tenantId));
}
[Fact]
public async Task Worker_processes_content_export_and_statistics_aggregation()
{
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Worker Export Tenant"
},
new User { Id = userId, Phone = "13900006666", Name = "Worker Student" },
new StudentProfile { TenantId = tenantId, UserId = userId },
new PracticeSession { TenantId = tenantId, UserId = userId, QuestionCount = 1 },
new Order
{
TenantId = tenantId,
UserId = userId,
OrderNo = "WORKER-STATS-ORDER",
Status = OrderStatus.Paid,
AmountCents = 800,
PaidAt = DateTimeOffset.UtcNow
});
using var scope = factory.CreateSystemScope();
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
var exportJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"content_export",
JsonSerializer.SerializeToElement(new { exportType = "students" })));
var statsJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"statistics_aggregation",
JsonSerializer.SerializeToElement(new { scope = "tenant" })));
var processed = await jobService.ProcessPendingAsync("integration-worker", 10);
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedExportJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == exportJob.Id);
var storedStatsJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == statsJob.Id);
Assert.Equal(2, processed);
Assert.Equal(BackgroundJobStatus.Succeeded, storedExportJob.Status);
Assert.NotNull(storedExportJob.OutputAssetId);
Assert.True(await dbContext.ContentAssets.AnyAsync(item => item.Id == storedExportJob.OutputAssetId));
Assert.Equal(BackgroundJobStatus.Succeeded, storedStatsJob.Status);
Assert.Equal(800, storedStatsJob.Result.GetProperty("revenueCents").GetInt32());
}
[Fact]
public async Task Admin_can_manage_points_and_coupons()
{
@@ -252,6 +344,266 @@ public sealed class TenantCommerceEndpointTests
Assert.Equal(HttpStatusCode.OK, allowedRefund.StatusCode);
}
[Fact]
public async Task Admin_can_preview_import_and_query_reconciliation_operations()
{
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var rows = JsonSerializer.SerializeToElement(new object[]
{
new
{
transactionType = "payment",
providerTradeNo = "wx-trade-1",
orderNo = "ORDER-1",
amountCents = 1000,
providerStatus = "paid",
localStatus = "paid",
matchStatus = "matched"
},
new
{
transactionType = "refund",
providerTradeNo = "wx-trade-2",
providerRefundNo = "wx-refund-2",
orderNo = "ORDER-2",
refundNo = "REFUND-2",
refundAmountCents = 300,
providerStatus = "succeeded",
localStatus = "processing",
matchStatus = "status_mismatch",
issueCode = "refund_status_mismatch"
}
});
var request = new PreviewReconciliationImportDto
{
Provider = PaymentProviders.WechatPay,
BillDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
BillType = ReconciliationBillType.Combined,
SourceName = "wechat-bill.csv",
Rows = rows
};
var previewResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/preview", request);
var preview = await previewResponse.Content.ReadFromJsonAsync<ReconciliationImportPreview>(JsonOptions);
var importResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/import", request);
var batch = await importResponse.Content.ReadFromJsonAsync<CommerceReconciliationBatch>(JsonOptions);
var itemsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/items?batchId={batch!.Id}");
var items = await itemsResponse.Content.ReadFromJsonAsync<TenantReconciliationItemList>(JsonOptions);
var issuesResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/issues");
var issues = await issuesResponse.Content.ReadFromJsonAsync<TenantReconciliationIssueList>(JsonOptions);
var issueEventsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}");
var anomalies = await client.GetFromJsonAsync<TenantCommerceAnomalySummary>("/api/tenant-commerce/reconciliation/anomalies", JsonOptions);
var accountResponse = await client.PutAsJsonAsync(
"/api/tenant-commerce/payment-accounts",
new UpsertPaymentAccountDto
{
Provider = PaymentProviders.WechatPay,
Status = TenantExternalProviderStatus.Active,
ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch" })
});
var jobResponse = await client.PostAsJsonAsync(
"/api/tenant-commerce/reconciliation/provider-bills/request",
new RequestProviderBillJobDto
{
Provider = PaymentProviders.WechatPay,
BillDate = request.BillDate,
BillType = ReconciliationBillType.Combined
});
var jobsResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/provider-bills/jobs?limit=5");
var jobs = await jobsResponse.Content.ReadFromJsonAsync<IReadOnlyCollection<BackgroundJobItem>>(JsonOptions);
using var scope = factory.CreateSystemScope();
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
var processedJobs = await jobService.ProcessPendingAsync("integration-worker", 10);
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var providerBatch = await dbContext.CommerceReconciliationBatches.AsNoTracking()
.SingleAsync(item =>
item.TenantId == seed.TenantId &&
item.Provider == PaymentProviders.WechatPay &&
item.Source == ReconciliationSource.ProviderDownload);
var storedJob = await dbContext.BackgroundJobs.AsNoTracking()
.SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "commerce_reconciliation");
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
Assert.NotNull(preview);
Assert.Equal(2, preview.TotalCount);
Assert.Equal(1, preview.PaymentCount);
Assert.Equal(1, preview.RefundCount);
Assert.Equal(1, preview.InvalidCount);
Assert.Equal(1000, preview.AmountCents);
Assert.Equal(300, preview.RefundAmountCents);
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
Assert.Equal(ReconciliationBatchStatus.CompletedWithIssues, batch.Status);
Assert.Equal(HttpStatusCode.OK, itemsResponse.StatusCode);
Assert.NotNull(items);
Assert.Equal(2, items.Items.Count);
Assert.Equal(HttpStatusCode.OK, issuesResponse.StatusCode);
Assert.Single(issues.Items);
Assert.Equal(HttpStatusCode.OK, issueEventsResponse.StatusCode);
Assert.NotNull(anomalies);
Assert.True(anomalies.OpenReconciliationIssueCount >= 1);
Assert.True(anomalies.PaymentMismatchCount >= 1);
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, jobResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, jobsResponse.StatusCode);
Assert.NotNull(jobs);
Assert.Contains(jobs, item => item.JobType == "commerce_reconciliation");
Assert.True(processedJobs >= 1);
Assert.Equal(ReconciliationBatchStatus.Pending, providerBatch.Status);
Assert.Equal(BackgroundJobStatus.Succeeded, storedJob.Status);
Assert.Equal(providerBatch.Id, storedJob.Result.GetProperty("batchId").GetGuid());
}
[Fact]
public async Task Refund_notification_is_idempotent_and_updates_order_once()
{
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "REFUND-NOTIFY-ORDER");
var payment = new Payment
{
TenantId = seed.TenantId,
OrderId = order.Id,
Provider = PaymentProviders.WechatPay,
Method = "jsapi",
Status = PaymentStatus.Paid,
AmountCents = order.AmountCents,
ProviderTradeNo = "wx-paid-trade",
PaidAt = DateTimeOffset.UtcNow
};
var refund = new CommerceRefundRequest
{
TenantId = seed.TenantId,
OrderId = order.Id,
PaymentId = payment.Id,
RequestedBy = seed.UserId,
ReviewedBy = seed.UserId,
ProcessedBy = seed.UserId,
RefundNo = "REFUND-NOTIFY-1",
Provider = PaymentProviders.WechatPay,
Status = CommerceRefundStatus.Processing,
AmountCents = 400,
Reason = "integration test",
RequestedAt = DateTimeOffset.UtcNow,
ReviewedAt = DateTimeOffset.UtcNow,
ProcessedAt = DateTimeOffset.UtcNow
};
await factory.SeedAsync(order, payment, refund);
using var client = factory.CreateClient();
var request = new RefundNotificationDto
{
RefundNo = refund.RefundNo,
ProviderRefundNo = "wx-refund-notify-1",
Status = CommerceRefundStatus.Succeeded,
EventId = "refund-event-1",
Payload = JsonSerializer.SerializeToElement(new { status = "SUCCESS" })
};
var first = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request);
var second = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedOrder = await dbContext.Orders.AsNoTracking().SingleAsync(item => item.Id == order.Id);
var storedPayment = await dbContext.Payments.AsNoTracking().SingleAsync(item => item.Id == payment.Id);
var storedRefund = await dbContext.CommerceRefundRequests.AsNoTracking().SingleAsync(item => item.Id == refund.Id);
var matchingEvents = await dbContext.PaymentEvents.AsNoTracking()
.CountAsync(item =>
item.TenantId == seed.TenantId &&
item.Provider == PaymentProviders.WechatPay &&
item.EventType == "refund" &&
item.EventId == "refund-event-1");
var refundEvents = await dbContext.CommerceRefundEvents.AsNoTracking()
.CountAsync(item => item.TenantId == seed.TenantId && item.RefundRequestId == refund.Id);
Assert.Equal(400, storedOrder.RefundedAmountCents);
Assert.Equal(OrderStatus.PartiallyRefunded, storedOrder.Status);
Assert.Equal(400, storedPayment.RefundedAmountCents);
Assert.Equal(PaymentStatus.PartiallyRefunded, storedPayment.Status);
Assert.Equal(CommerceRefundStatus.Succeeded, storedRefund.Status);
Assert.Equal("wx-refund-notify-1", storedRefund.ProviderRefundNo);
Assert.Equal(1, matchingEvents);
Assert.Equal(1, refundEvents);
}
[Fact]
public async Task Admin_can_create_review_and_report_adjustment_vouchers()
{
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "ADJUSTMENT-ORDER");
var payment = new Payment
{
TenantId = seed.TenantId,
OrderId = order.Id,
Provider = PaymentProviders.WechatPay,
Method = "jsapi",
Status = PaymentStatus.Paid,
AmountCents = order.AmountCents,
ProviderTradeNo = "adjustment-trade",
PaidAt = DateTimeOffset.UtcNow
};
await factory.SeedAsync(order, payment);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var createResponse = await client.PostAsJsonAsync(
"/api/tenant-commerce/adjustment-vouchers",
new CreateAdjustmentVoucherDto
{
OrderId = order.Id,
PaymentId = payment.Id,
Direction = CommerceAdjustmentDirection.IncreaseRevenue,
AmountCents = 100,
Reason = "manual reconciliation adjustment",
ProofAssetKey = "proofs/adjustment.txt"
});
var created = await createResponse.Content.ReadFromJsonAsync<CommerceAdjustmentVoucher>(JsonOptions);
var pendingResponse = await client.PostAsJsonAsync(
"/api/tenant-commerce/adjustment-vouchers/status",
new UpdateAdjustmentVoucherStatusDto
{
VoucherId = created!.Id,
Status = CommerceAdjustmentVoucherStatus.PendingReview,
Note = "submit"
});
var approvedResponse = await client.PostAsJsonAsync(
"/api/tenant-commerce/adjustment-vouchers/status",
new UpdateAdjustmentVoucherStatusDto
{
VoucherId = created.Id,
Status = CommerceAdjustmentVoucherStatus.Approved,
Note = "approved"
});
var detailResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/detail?voucherId={created.Id}");
var eventsResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/events?voucherId={created.Id}");
var listResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers?status=approved");
var reportResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers/report");
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, pendingResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, approvedResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
Assert.Contains("increaseRevenueCents", await reportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(CommerceAdjustmentVoucherStatus.Approved, await dbContext.CommerceAdjustmentVouchers
.Where(item => item.Id == created.Id)
.Select(item => item.Status)
.SingleAsync());
Assert.Equal(3, await dbContext.CommerceAdjustmentVoucherEvents.CountAsync(item => item.VoucherId == created.Id));
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
item.TenantId == seed.TenantId &&
item.Action == "commerce.adjustment_voucher.status_changed"));
}
private static async Task<LoginSeed> SeedLoginUserAsync(ApiTestFactory factory, TenantRole role)
{
var tenantId = Guid.NewGuid();
@@ -286,7 +638,7 @@ public sealed class TenantCommerceEndpointTests
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid regionId, string orderNo) => new()
private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid? regionId, string orderNo) => new()
{
TenantId = tenantId,
UserId = userId,