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; 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() { 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 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(); 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(); 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(); 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(); 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() { 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); } [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(JsonOptions); var importResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/import", request); var batch = await importResponse.Content.ReadFromJsonAsync(JsonOptions); var itemsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/items?batchId={batch!.Id}"); var items = await itemsResponse.Content.ReadFromJsonAsync(JsonOptions); var issuesResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/issues"); var issues = await issuesResponse.Content.ReadFromJsonAsync(JsonOptions); var issueEventsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}"); var anomalies = await client.GetFromJsonAsync("/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>(JsonOptions); using var scope = factory.CreateSystemScope(); var jobService = scope.ServiceProvider.GetRequiredService(); var processedJobs = await jobService.ProcessPendingAsync("integration-worker", 10); var dbContext = scope.ServiceProvider.GetRequiredService(); 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(); 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(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(); 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 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(); } } }