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.Auth; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class TenantCommerceEndpointTests { [Fact] public async Task Non_admin_cannot_access_tenant_commerce_operations() { await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); var seed = await SeedLoginUserAsync(factory, TenantRole.Student); using var client = factory.CreateClient(); await LoginAsync(client, seed); var response = await client.GetAsync("/api/tenant-commerce/orders"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Payment_account_public_config_rejects_secrets() { 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 response = await client.PutAsJsonAsync( "/api/tenant-commerce/payment-accounts", new UpsertPaymentAccountDto { Provider = "wechat_pay", Status = TenantPaymentAccountStatus.Active, ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch", apiV3Key = "must-not-be-public" }) }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task Admin_can_configure_payment_account_and_secret() { 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 secretResponse = await client.PutAsJsonAsync( "/api/tenant-commerce/secrets", new UpsertTenantSecretDto { Purpose = "payment", Provider = "wechat_pay", SecretKey = "default", SecretRef = "tenant_secrets:payment:wechat_pay:default", SecretPayload = JsonSerializer.SerializeToElement(new { privateKey = "pem", apiV3Key = "v3" }) }); var accountResponse = await client.PutAsJsonAsync( "/api/tenant-commerce/payment-accounts", new UpsertPaymentAccountDto { Provider = "wechat_pay", Status = TenantPaymentAccountStatus.Active, ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch", secretRef = "tenant_secrets:payment:wechat_pay:default" }) }); var accountsResponse = await client.GetAsync("/api/tenant-commerce/payment-accounts?provider=wechat_pay"); Assert.Equal(HttpStatusCode.OK, secretResponse.StatusCode); Assert.DoesNotContain("privateKey", await secretResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode); Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var storedSecret = await dbContext.TenantSecrets .AsNoTracking() .SingleAsync(item => item.TenantId == seed.TenantId); var configService = scope.ServiceProvider.GetRequiredService(); var resolvedAccount = await configService.GetActiveAccountAsync(seed.TenantId, "wechat_pay"); Assert.Equal("development-v1", storedSecret.EncryptionKeyId); Assert.NotEmpty(storedSecret.EncryptedPayload); Assert.Equal(12, storedSecret.EncryptionNonce.Length); Assert.Equal(16, storedSecret.EncryptionTag.Length); Assert.Equal("pem", resolvedAccount.SecretPayload.GetProperty("privateKey").GetString()); Assert.Equal("v3", resolvedAccount.SecretPayload.GetProperty("apiV3Key").GetString()); } [Fact] public async Task Admin_can_create_and_redeem_activation_code_batch() { 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 batchResponse = await client.PostAsJsonAsync( "/api/tenant-commerce/code-batches", new CreateCodeBatchDto { Name = "测试批次", TotalCount = 2, Days = 30 }); var codesResponse = await client.GetAsync("/api/tenant-commerce/activation-codes?status=unused&limit=5"); var codes = await codesResponse.Content.ReadFromJsonAsync(); var code = codes!.Items.First().Code; var redeemResponse = await client.PostAsJsonAsync( "/api/tenant-commerce/activation-codes/redeem", new RedeemActivationCodeDto { Code = code, UserId = seed.UserId }); Assert.Equal(HttpStatusCode.OK, batchResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, codesResponse.StatusCode); Assert.Equal(2, codes.Items.Count); Assert.Equal(HttpStatusCode.OK, redeemResponse.StatusCode); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Contains(dbContext.Entitlements, item => item.TenantId == seed.TenantId && item.UserId == seed.UserId && item.SourceType == "activation_code"); } [Fact] public async Task Admin_can_manage_points_and_coupons() { 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 taskResponse = await client.PutAsJsonAsync( "/api/tenant-commerce/point-activity-tasks", new UpsertPointTaskDto { TaskKey = "admin_daily", Title = "后台每日任务", TaskType = PointActivityTaskType.DailyLogin, Points = 10, MaxClaimsPerUser = 1 }); var tasksResponse = await client.GetAsync("/api/tenant-commerce/point-activity-tasks"); var exchangeItemResponse = await client.PutAsJsonAsync( "/api/tenant-commerce/point-exchange-items", new UpsertPointExchangeItemDto { ItemKey = "admin_svip_7d", Name = "后台 SVIP 7 天", ItemType = PointExchangeItemType.Entitlement, PointsCost = 30, Days = 7, Stock = 10 }); var exchangeItemsResponse = await client.GetAsync("/api/tenant-commerce/point-exchange-items"); var couponResponse = await client.PutAsJsonAsync( "/api/tenant-commerce/coupons", new UpsertTenantCouponDto { Code = "ADMIN10", DiscountType = DiscountType.Fixed, DiscountValue = 10, MaxUses = 100 }); var couponsResponse = await client.GetAsync("/api/tenant-commerce/coupons"); var reportResponse = await client.GetAsync("/api/tenant-commerce/coupons/report"); Assert.Equal(HttpStatusCode.OK, taskResponse.StatusCode); Assert.Contains("admin_daily", await tasksResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, exchangeItemResponse.StatusCode); Assert.Contains("admin_svip_7d", await exchangeItemsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, couponResponse.StatusCode); Assert.Contains("ADMIN10", await couponsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode); } private static async Task SeedLoginUserAsync(ApiTestFactory factory, TenantRole role) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13800000000"; var passwordHash = new PasswordHasher().Hash("passw0rd!"); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Tenant Commerce" }, new User { Id = userId, Phone = phone, Name = "Tenant Commerce User" }, new TenantMembership { TenantId = tenantId, UserId = userId, Role = role, Status = MembershipStatus.Active }, new UserIdentity { UserId = userId, Provider = "password", ProviderSubject = phone, Phone = phone, SecretPayload = CreateSecretPayload(passwordHash) }); return new LoginSeed(tenantId, userId, phone); } private static async Task LoginAsync(HttpClient client, LoginSeed seed) { var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" }); loginResponse.EnsureSuccessStatusCode(); using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); } private static JsonElement CreateSecretPayload(string passwordHash) { using var document = JsonDocument.Parse( $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); private sealed class FakePaymentGateway : IPaymentProviderGateway { public Task CreatePaymentAsync( string provider, CreatePaymentProviderRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } public Task ParsePaymentNotificationAsync( string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } } }