Files
tiku-backend.net/Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

277 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Domain.Common;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class PlatformBillingCallbackTests
{
[Theory]
[InlineData("wechat_pay", "application/json", "{\"code\":\"SUCCESS\"")]
[InlineData("alipay", "text/plain", "success")]
public async Task Valid_provider_callback_settles_once(
string provider,
string expectedMediaType,
string expectedBody)
{
var fixture = CreateFixture(provider);
var gateway = new FakePlatformBillingPaymentGateway(fixture.Notification);
await using var factory = new ApiTestFactory(platformBillingPaymentGateway: gateway);
await factory.SeedAsync(fixture.Entities);
await PublishVersionAsync(factory, fixture.VersionId);
using var client = factory.CreateClient();
var first = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}",
new { eventId = fixture.Notification.EventId });
var repeated = await client.PostAsJsonAsync($"/api/integrations/platform-billing/callbacks/{provider}",
new { eventId = fixture.Notification.EventId });
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeated.StatusCode);
Assert.Equal(expectedMediaType, first.Content.Headers.ContentType?.MediaType);
Assert.Contains(expectedBody, await first.Content.ReadAsStringAsync(), StringComparison.Ordinal);
Assert.All(gateway.ParsedProviders, value => Assert.Equal(provider, value));
using var scope = factory.CreateSystemScope("Verify platform payment callback idempotency");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(PlatformBillingPaymentStatus.Succeeded,
(await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == fixture.PaymentId))
.Status);
Assert.Single(await db.PlatformBillingPaymentEvents.AsNoTracking()
.Where(value => value.PaymentId == fixture.PaymentId)
.ToArrayAsync());
Assert.Single(await db.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.OrderId == fixture.OrderId)
.ToArrayAsync());
Assert.Single(await db.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == fixture.TenantId)
.ToArrayAsync());
}
[Fact]
public async Task Invalid_signature_is_rejected_without_settlement()
{
var fixture = CreateFixture(PaymentProviders.WechatPay);
var invalid = fixture.Notification with { SignatureValid = false };
await using var factory = new ApiTestFactory(
platformBillingPaymentGateway: new FakePlatformBillingPaymentGateway(invalid));
await factory.SeedAsync(fixture.Entities);
await PublishVersionAsync(factory, fixture.VersionId);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/integrations/platform-billing/callbacks/wechat_pay",
new { eventId = invalid.EventId });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("platform_billing_notification_invalid", await ReadCodeAsync(response));
await AssertPaymentRemainsPendingAsync(factory, fixture.PaymentId);
}
[Fact]
public async Task Callback_amount_mismatch_is_rejected_without_settlement()
{
var fixture = CreateFixture(PaymentProviders.Alipay);
var mismatched = fixture.Notification with { AmountCents = fixture.Notification.AmountCents + 1 };
await using var factory = new ApiTestFactory(
platformBillingPaymentGateway: new FakePlatformBillingPaymentGateway(mismatched));
await factory.SeedAsync(fixture.Entities);
await PublishVersionAsync(factory, fixture.VersionId);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/integrations/platform-billing/callbacks/alipay",
new { eventId = mismatched.EventId });
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("platform_billing_payment_amount_mismatch", await ReadCodeAsync(response));
await AssertPaymentRemainsPendingAsync(factory, fixture.PaymentId);
}
private static CallbackFixture CreateFixture(string provider)
{
const int amountCents = 8_800;
var tenantId = Guid.NewGuid();
var feature = new SaasFeature
{
Code = $"callback.feature.{Guid.NewGuid():N}",
Name = "Callback feature",
Category = "integration",
Status = SaasFeatureStatus.Active
};
var offering = new SaasOffering
{
Id = Guid.NewGuid(),
Code = $"callback-offering-{Guid.NewGuid():N}",
Name = "Callback offering",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
};
var version = new SaasOfferingVersion
{
Id = Guid.NewGuid(),
OfferingId = offering.Id,
Version = 1,
Status = SaasOfferingVersionStatus.Draft,
BillingCycle = PlatformBillingCycle.Monthly,
OriginalAmountCents = amountCents,
AmountCents = amountCents,
Currency = "CNY",
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1)
};
var order = new PlatformBillingOrder
{
Id = Guid.NewGuid(),
TenantId = tenantId,
QuoteId = Guid.NewGuid(),
OrderNo = $"SO{Guid.NewGuid():N}",
IdempotencyKey = $"order-{Guid.NewGuid():N}",
Purpose = PlatformBillingOrderPurpose.NewSubscription,
Status = PlatformBillingOrderStatus.PendingPayment,
OriginalAmountCents = amountCents,
TotalAmountCents = amountCents,
Currency = "CNY",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Snapshot = JsonDefaults.Object()
};
var quote = new PlatformBillingQuote
{
Id = order.QuoteId,
TenantId = tenantId,
QuoteNo = $"SQ{Guid.NewGuid():N}",
IdempotencyKey = $"quote-{Guid.NewGuid():N}",
Purpose = PlatformBillingOrderPurpose.NewSubscription,
Status = PlatformBillingQuoteStatus.Converted,
OriginalAmountCents = amountCents,
TotalAmountCents = amountCents,
Currency = "CNY",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
FeatureSnapshot = JsonDefaults.Array(),
LimitSnapshot = JsonDefaults.Object()
};
var orderItem = new PlatformBillingOrderItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
OrderId = order.Id,
OfferingVersionId = version.Id,
ItemType = PlatformBillingItemType.BasePlan,
UnitAmountCents = amountCents,
AmountCents = amountCents,
Snapshot = JsonDefaults.Object()
};
var payment = new PlatformBillingPayment
{
Id = Guid.NewGuid(),
TenantId = tenantId,
OrderId = order.Id,
PaymentNo = $"SP{Guid.NewGuid():N}",
IdempotencyKey = $"payment-{Guid.NewGuid():N}",
Provider = provider,
Method = provider == PaymentProviders.Alipay ? "web" : "jsapi",
AmountCents = amountCents,
Status = PlatformBillingPaymentStatus.Pending
};
var notification = new PaymentNotificationResult(
provider,
"payment.succeeded",
$"event-{Guid.NewGuid():N}",
order.OrderNo,
$"trade-{Guid.NewGuid():N}",
amountCents,
true,
true,
DateTimeOffset.UtcNow,
JsonDefaults.Object());
object[] entities =
[
new Tenant
{
Id = tenantId,
Slug = $"callback-{tenantId:N}",
Name = "Callback tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Trial
},
feature,
offering,
version,
new SaasOfferingVersionFeature { OfferingVersionId = version.Id, FeatureCode = feature.Code },
quote,
order,
orderItem,
payment
];
return new CallbackFixture(tenantId, version.Id, order.Id, payment.Id, notification, entities);
}
private static async Task PublishVersionAsync(ApiTestFactory factory, Guid versionId)
{
using var scope = factory.CreateSystemScope("Publish callback plan fixture");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await db.SaasOfferingVersions.SingleAsync(value => value.Id == versionId);
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
private static async Task AssertPaymentRemainsPendingAsync(ApiTestFactory factory, Guid paymentId)
{
using var scope = factory.CreateSystemScope("Verify rejected platform callback");
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(PlatformBillingPaymentStatus.Pending,
(await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == paymentId)).Status);
Assert.False(await db.PlatformBillingPaymentEvents.AnyAsync(value => value.PaymentId == paymentId));
}
private static async Task<string> ReadCodeAsync(HttpResponseMessage response)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return payload.RootElement.GetProperty("code").GetString() ?? string.Empty;
}
private sealed record CallbackFixture(
Guid TenantId,
Guid VersionId,
Guid OrderId,
Guid PaymentId,
PaymentNotificationResult Notification,
object[] Entities);
private sealed class FakePlatformBillingPaymentGateway(PaymentNotificationResult notification)
: IPlatformBillingPaymentGateway
{
public List<string> ParsedProviders { get; } = [];
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
string provider,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new CreatePaymentProviderResult(
provider,
request.Method,
"pending",
null,
JsonDefaults.Object(),
JsonDefaults.Object()));
}
public Task<PaymentNotificationResult> ParseNotificationAsync(
string provider,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default)
{
ParsedProviders.Add(provider);
return Task.FromResult(notification);
}
}
}