Files
tiku-backend.net/Tiku.IntegrationTests/Api/CommerceEndpointTests.cs

436 lines
16 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
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
{
private static readonly JwtOptions JwtOptions = new()
{
Issuer = "tiku-backend",
Audience = "tiku-api",
SigningKey = "development-only-tiku-signing-key-change-before-production"
};
[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",
CreateToken([
new Claim(TikuClaimTypes.UserId, userId.ToString()),
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.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 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?tenantId={seed.TenantId}",
payload);
var secondNotify = await client.PostAsJsonAsync(
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
payload);
await LoginAsync(client, seed);
var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current"))
.Content
.ReadFromJsonAsync<CurrentEntitlementItem>();
using var scope = factory.Services.CreateScope();
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?tenantId={seed.TenantId}",
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 passwordHash = new PasswordHasher().Hash("passw0rd!");
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"
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
},
new SvipPlan
{
Id = planId,
TenantId = tenantId,
Name = "月卡",
PriceCents = 999,
Days = 39,
IsActive = true
},
new TenantPaymentAccount
{
TenantId = tenantId,
Provider = "manual",
Status = TenantPaymentAccountStatus.Active
}
};
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)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Password = "passw0rd!"
});
loginResponse.EnsureSuccessStatusCode();
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
private static string CreateToken(IEnumerable<Claim> claims)
{
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
JwtOptions.Issuer,
JwtOptions.Audience,
claims,
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
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;
}
}
}