462 lines
18 KiB
C#
462 lines
18 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Api.Contracts;
|
|
using Tiku.Api.Options;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Application.Commerce;
|
|
using Tiku.Application.Security;
|
|
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 CommerceEndpointTests
|
|
{
|
|
[Fact]
|
|
public async Task Anonymous_commerce_request_returns_401()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
using var client = factory.CreateClient();
|
|
|
|
var response = await client.GetAsync("/api/commerce/orders");
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Non_member_cannot_create_order()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
|
|
var planId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new SvipPlan
|
|
{
|
|
Id = planId,
|
|
TenantId = tenantId,
|
|
Name = "月卡",
|
|
PriceCents = 999,
|
|
Days = 39,
|
|
IsActive = true
|
|
});
|
|
using var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new(
|
|
"Bearer",
|
|
TestJwtKeys.CreateToken([
|
|
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
|
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
|
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
|
]));
|
|
|
|
var response = await client.PostAsJsonAsync(
|
|
"/api/commerce/orders",
|
|
new CreateCommerceOrderDto
|
|
{
|
|
PlanId = planId,
|
|
Quantity = 1
|
|
});
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Student_can_create_and_query_svip_order()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
var createResponse = await client.PostAsJsonAsync(
|
|
"/api/commerce/orders",
|
|
new CreateCommerceOrderDto
|
|
{
|
|
PlanId = seed.PlanId,
|
|
Quantity = 2,
|
|
PayMethod = "manual",
|
|
PayProvider = "manual"
|
|
});
|
|
var created = await createResponse.Content.ReadFromJsonAsync<CommerceOrderItem>();
|
|
var listResponse = await client.GetAsync("/api/commerce/orders?limit=5");
|
|
var list = await listResponse.Content.ReadFromJsonAsync<CommerceOrderList>();
|
|
var detailResponse = await client.GetAsync($"/api/commerce/orders/{created!.OrderNo}");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
|
|
Assert.Equal(1998, created.AmountCents);
|
|
Assert.Equal(78, created.Days);
|
|
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
|
Assert.Contains(list!.Items, item => item.OrderNo == created.OrderNo);
|
|
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Paid_provider_result_marks_order_paid_and_grants_entitlement()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway("paid"));
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
var order = await CreateOrderAsync(client, seed.PlanId);
|
|
|
|
var paymentResponse = await client.PostAsJsonAsync(
|
|
"/api/commerce/payments",
|
|
new CreateCommercePaymentDto
|
|
{
|
|
OrderNo = order.OrderNo,
|
|
Provider = "manual",
|
|
Method = "manual"
|
|
});
|
|
var payment = await paymentResponse.Content.ReadFromJsonAsync<CommercePaymentItem>();
|
|
var entitlementResponse = await client.GetAsync("/api/commerce/entitlements/current");
|
|
var entitlement = await entitlementResponse.Content.ReadFromJsonAsync<CurrentEntitlementItem>();
|
|
var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}");
|
|
var paidOrder = await orderResponse.Content.ReadFromJsonAsync<CommerceOrderItem>();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, paymentResponse.StatusCode);
|
|
Assert.Equal("Paid", payment!.Status);
|
|
Assert.Equal("Paid", paidOrder!.Status);
|
|
Assert.Equal(HttpStatusCode.OK, entitlementResponse.StatusCode);
|
|
Assert.True(entitlement!.IsActive);
|
|
Assert.Equal("svip", entitlement.EntitlementType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Pending_payment_can_be_requested_idempotently()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
var order = await CreateOrderAsync(client, seed.PlanId);
|
|
var request = new CreateCommercePaymentDto
|
|
{
|
|
OrderNo = order.OrderNo,
|
|
Provider = "manual",
|
|
Method = "manual"
|
|
};
|
|
|
|
var first = await client.PostAsJsonAsync("/api/commerce/payments", request);
|
|
var second = await client.PostAsJsonAsync("/api/commerce/payments", request);
|
|
var firstPayment = await first.Content.ReadFromJsonAsync<CommercePaymentItem>();
|
|
var secondPayment = await second.Content.ReadFromJsonAsync<CommercePaymentItem>();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
|
Assert.Equal(firstPayment!.Id, secondPayment!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Student_can_claim_and_check_coupon()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
await factory.SeedAsync(new Coupon
|
|
{
|
|
TenantId = seed.TenantId,
|
|
Code = "SAVE5",
|
|
PlanId = seed.PlanId,
|
|
DiscountType = DiscountType.Fixed,
|
|
DiscountValue = 5,
|
|
MaxUses = 10
|
|
});
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
var claimResponse = await client.PostAsJsonAsync(
|
|
"/api/commerce/coupons/claim",
|
|
new ClaimCommerceCouponDto { CouponCode = "SAVE5" });
|
|
var coupons = await (await client.GetAsync("/api/commerce/coupons"))
|
|
.Content
|
|
.ReadFromJsonAsync<CommerceCouponList>();
|
|
var check = await (await client.PostAsJsonAsync(
|
|
"/api/commerce/coupons/check",
|
|
new CheckCommerceCouponDto
|
|
{
|
|
CouponCode = "SAVE5",
|
|
PlanId = seed.PlanId,
|
|
Quantity = 1
|
|
}))
|
|
.Content
|
|
.ReadFromJsonAsync<CommerceCouponCheckResult>();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, claimResponse.StatusCode);
|
|
Assert.Contains(coupons!.Items, item => item.CouponCode == "SAVE5" && item.Status == "Claimed");
|
|
Assert.True(check!.Valid);
|
|
Assert.Equal(500, check.DiscountCents);
|
|
Assert.Equal(499, check.PayableAmountCents);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Coupon_can_make_order_zero_amount_and_grant_entitlement()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
await factory.SeedAsync(new Coupon
|
|
{
|
|
TenantId = seed.TenantId,
|
|
Code = "FREE",
|
|
PlanId = seed.PlanId,
|
|
DiscountType = DiscountType.Fixed,
|
|
DiscountValue = 9.99m,
|
|
MaxUses = 10
|
|
});
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
var response = await client.PostAsJsonAsync(
|
|
"/api/commerce/orders",
|
|
new CreateCommerceOrderDto
|
|
{
|
|
PlanId = seed.PlanId,
|
|
Quantity = 1,
|
|
CouponCode = "FREE"
|
|
});
|
|
var order = await response.Content.ReadFromJsonAsync<CommerceOrderItem>();
|
|
var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current"))
|
|
.Content
|
|
.ReadFromJsonAsync<CurrentEntitlementItem>();
|
|
var coupons = await (await client.GetAsync("/api/commerce/coupons?status=used"))
|
|
.Content
|
|
.ReadFromJsonAsync<CommerceCouponList>();
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(0, order!.AmountCents);
|
|
Assert.Equal("Paid", order.Status);
|
|
Assert.True(entitlement!.IsActive);
|
|
Assert.Contains(coupons!.Items, item => item.CouponCode == "FREE" && item.DiscountPreviewCents == 999);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Payment_notification_marks_order_paid_once()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
var order = await CreateOrderAsync(client, seed.PlanId);
|
|
var request = new CreateCommercePaymentDto
|
|
{
|
|
OrderNo = order.OrderNo,
|
|
Provider = "manual",
|
|
Method = "manual"
|
|
};
|
|
await client.PostAsJsonAsync("/api/commerce/payments", request);
|
|
|
|
client.DefaultRequestHeaders.Authorization = null;
|
|
var payload = new
|
|
{
|
|
eventId = "notify-001",
|
|
orderNo = order.OrderNo,
|
|
providerTradeNo = "trade-notify-001",
|
|
amountCents = order.AmountCents,
|
|
paid = true,
|
|
signatureValid = true
|
|
};
|
|
var firstNotify = await client.PostAsJsonAsync(
|
|
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
|
payload);
|
|
var secondNotify = await client.PostAsJsonAsync(
|
|
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
|
payload);
|
|
await LoginAsync(client, seed);
|
|
var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current"))
|
|
.Content
|
|
.ReadFromJsonAsync<CurrentEntitlementItem>();
|
|
using var scope = factory.CreateSystemScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var events = dbContext.PaymentEvents.Count(item => item.EventId == "notify-001");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, firstNotify.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, secondNotify.StatusCode);
|
|
Assert.True(entitlement!.IsActive);
|
|
Assert.Equal(1, events);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invalid_payment_notification_signature_does_not_pay_order()
|
|
{
|
|
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
|
var seed = await SeedLoginUserAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
var order = await CreateOrderAsync(client, seed.PlanId);
|
|
|
|
client.DefaultRequestHeaders.Authorization = null;
|
|
var notifyResponse = await client.PostAsJsonAsync(
|
|
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
|
new
|
|
{
|
|
eventId = "notify-invalid",
|
|
orderNo = order.OrderNo,
|
|
providerTradeNo = "trade-invalid",
|
|
amountCents = order.AmountCents,
|
|
paid = true,
|
|
signatureValid = false
|
|
});
|
|
await LoginAsync(client, seed);
|
|
var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}");
|
|
var currentOrder = await orderResponse.Content.ReadFromJsonAsync<CommerceOrderItem>();
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, notifyResponse.StatusCode);
|
|
Assert.Equal("Pending", currentOrder!.Status);
|
|
}
|
|
|
|
private static async Task<CommerceOrderItem> CreateOrderAsync(HttpClient client, Guid planId)
|
|
{
|
|
var response = await client.PostAsJsonAsync(
|
|
"/api/commerce/orders",
|
|
new CreateCommerceOrderDto
|
|
{
|
|
PlanId = planId,
|
|
Quantity = 1,
|
|
PayMethod = "manual",
|
|
PayProvider = "manual"
|
|
});
|
|
response.EnsureSuccessStatusCode();
|
|
return (await response.Content.ReadFromJsonAsync<CommerceOrderItem>())!;
|
|
}
|
|
|
|
private static async Task<LoginSeed> SeedLoginUserAsync(
|
|
ApiTestFactory factory,
|
|
bool includeMembership = true)
|
|
{
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var planId = Guid.NewGuid();
|
|
var phone = "13800000000";
|
|
var entities = new List<object>
|
|
{
|
|
new Tenant
|
|
{
|
|
Id = tenantId,
|
|
Slug = tenantId.ToString("N"),
|
|
Name = "Commerce Tenant"
|
|
},
|
|
new User
|
|
{
|
|
Id = userId,
|
|
Phone = phone,
|
|
Name = "Commerce User"
|
|
}.WithTestPassword(),
|
|
new SvipPlan
|
|
{
|
|
Id = planId,
|
|
TenantId = tenantId,
|
|
Name = "月卡",
|
|
PriceCents = 999,
|
|
Days = 39,
|
|
IsActive = true
|
|
},
|
|
new TenantExternalProvider
|
|
{
|
|
TenantId = tenantId,
|
|
Capability = TenantExternalProviderCapability.Payment,
|
|
Provider = "manual",
|
|
Status = TenantExternalProviderStatus.Active,
|
|
ConfigPublic = JsonSerializer.SerializeToElement(new { mode = "TenantCollect" })
|
|
}
|
|
};
|
|
if (includeMembership)
|
|
{
|
|
entities.Add(new TenantMembership
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Role = TenantRole.Student,
|
|
Status = MembershipStatus.Active
|
|
});
|
|
}
|
|
|
|
await factory.SeedAsync(entities.ToArray());
|
|
return new LoginSeed(tenantId, userId, planId, phone);
|
|
}
|
|
|
|
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
|
{
|
|
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
|
}
|
|
|
|
private sealed record LoginSeed(Guid TenantId, Guid UserId, Guid PlanId, string Phone);
|
|
|
|
private sealed class FakePaymentGateway(string status = "pending") : IPaymentProviderGateway
|
|
{
|
|
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
|
|
string provider,
|
|
CreatePaymentProviderRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var payload = JsonSerializer.SerializeToElement(new
|
|
{
|
|
request.OrderNo,
|
|
request.AmountCents,
|
|
status
|
|
});
|
|
return Task.FromResult(new CreatePaymentProviderResult(
|
|
provider,
|
|
request.Method,
|
|
status,
|
|
status == "paid" ? $"trade-{request.OrderNo}" : null,
|
|
payload,
|
|
payload));
|
|
}
|
|
|
|
public Task<PaymentNotificationResult> ParsePaymentNotificationAsync(
|
|
string provider,
|
|
PaymentNotificationRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var body = request.Body;
|
|
var eventId = GetString(body, "eventId") ?? Guid.NewGuid().ToString("N");
|
|
var orderNo = GetString(body, "orderNo") ?? throw new InvalidOperationException("orderNo missing");
|
|
var tradeNo = GetString(body, "providerTradeNo");
|
|
var amount = GetInt(body, "amountCents");
|
|
var paid = GetBoolean(body, "paid");
|
|
var signatureValid = GetBoolean(body, "signatureValid");
|
|
return Task.FromResult(new PaymentNotificationResult(
|
|
provider,
|
|
"payment_notify",
|
|
eventId,
|
|
orderNo,
|
|
tradeNo,
|
|
amount,
|
|
paid,
|
|
signatureValid,
|
|
DateTimeOffset.UtcNow,
|
|
body));
|
|
}
|
|
|
|
private static string? GetString(JsonElement element, string key)
|
|
{
|
|
return element.ValueKind == JsonValueKind.Object &&
|
|
element.TryGetProperty(key, out var property) &&
|
|
property.ValueKind == JsonValueKind.String
|
|
? property.GetString()
|
|
: null;
|
|
}
|
|
|
|
private static int GetInt(JsonElement element, string key)
|
|
{
|
|
return element.ValueKind == JsonValueKind.Object &&
|
|
element.TryGetProperty(key, out var property) &&
|
|
property.TryGetInt32(out var value)
|
|
? value
|
|
: 0;
|
|
}
|
|
|
|
private static bool GetBoolean(JsonElement element, string key)
|
|
{
|
|
return element.ValueKind == JsonValueKind.Object &&
|
|
element.TryGetProperty(key, out var property) &&
|
|
property.ValueKind == JsonValueKind.True;
|
|
}
|
|
}
|
|
}
|