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.Catalog; 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 = TenantExternalProviderStatus.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 = TenantExternalProviderStatus.Active, SecretRef = "tenant_secrets:payment:wechat_pay:default", ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch" }) }); 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.CreateSystemScope(); 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.CreateSystemScope(); 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); } [Fact] public async Task OrderAndRefundOperations_ApplySelfAndRestrictedScopesInSql() { await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); var allowedRegionId = Guid.NewGuid(); var otherRegionId = Guid.NewGuid(); var regionalUserId = Guid.NewGuid(); var outsideUserId = Guid.NewGuid(); var ownOrder = NewPaidOrder(seed.TenantId, seed.UserId, otherRegionId, "SELF-ORDER"); var regionalOrder = NewPaidOrder(seed.TenantId, regionalUserId, allowedRegionId, "REGION-ORDER"); var outsideOrder = NewPaidOrder(seed.TenantId, outsideUserId, otherRegionId, "OUTSIDE-ORDER"); await factory.SeedAsync( new Region { Id = allowedRegionId, TenantId = seed.TenantId, Name = "Allowed Region" }, new Region { Id = otherRegionId, TenantId = seed.TenantId, Name = "Other Region" }, new User { Id = regionalUserId, Name = "Regional Buyer" }, new User { Id = outsideUserId, Name = "Outside Buyer" }, ownOrder, regionalOrder, outsideOrder); await SetAdminDataScopeAsync(factory, seed.TenantId, new { mode = "self" }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var selfOrders = await client.GetFromJsonAsync("/api/tenant-commerce/orders"); await SetAdminDataScopeAsync(factory, seed.TenantId, new { mode = "restricted", regionIds = new[] { allowedRegionId }, includesSelf = false }); var regionalOrders = await client.GetFromJsonAsync("/api/tenant-commerce/orders"); using var deniedRefund = await client.PostAsJsonAsync( "/api/tenant-commerce/refunds", new CreateRefundRequestDto { OrderId = outsideOrder.Id, AmountCents = 100 }); using var allowedRefund = await client.PostAsJsonAsync( "/api/tenant-commerce/refunds", new CreateRefundRequestDto { OrderId = regionalOrder.Id, AmountCents = 100 }); Assert.NotNull(selfOrders); Assert.Equal(["SELF-ORDER"], selfOrders.Items.Select(item => item.OrderNo)); Assert.NotNull(regionalOrders); Assert.Equal(["REGION-ORDER"], regionalOrders.Items.Select(item => item.OrderNo)); Assert.Equal(HttpStatusCode.NotFound, deniedRefund.StatusCode); Assert.Equal(HttpStatusCode.OK, allowedRefund.StatusCode); } private static async Task SeedLoginUserAsync(ApiTestFactory factory, TenantRole role) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13800000000"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Tenant Commerce" }, new User { Id = userId, Phone = phone, Name = "Tenant Commerce User" }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = role, Status = MembershipStatus.Active }); return new LoginSeed(tenantId, userId, phone); } private static async Task LoginAsync(HttpClient client, LoginSeed seed) { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid regionId, string orderNo) => new() { TenantId = tenantId, UserId = userId, RegionId = regionId, OrderNo = orderNo, Status = OrderStatus.Paid, AmountCents = 1_000, PaidAt = DateTimeOffset.UtcNow }; private static async Task SetAdminDataScopeAsync(ApiTestFactory factory, Guid tenantId, object value) { using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var role = await dbContext.TenantBackendRoles.SingleAsync(item => item.TenantId == tenantId && item.Code == "integration_test_admin"); role.DataScope = JsonSerializer.SerializeToElement(value); await dbContext.SaveChangesAsync(); } 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(); } } }