277 lines
11 KiB
C#
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);
|
|
}
|
|
}
|
|
} |