forked from gongxuegit/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
272
Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs
Normal file
272
Tiku.IntegrationTests/Api/PlatformBillingCallbackTests.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
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/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId });
|
||||
var repeated = await client.PostAsJsonAsync($"/api/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/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/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) =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user