feat: complete phase six backoffice operations
This commit is contained in:
@@ -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);
|
||||
|
||||
310
Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs
Normal file
310
Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user