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(); var listResponse = await client.GetAsync("/api/commerce/orders?limit=5"); var list = await listResponse.Content.ReadFromJsonAsync(); 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(); var entitlementResponse = await client.GetAsync("/api/commerce/entitlements/current"); var entitlement = await entitlementResponse.Content.ReadFromJsonAsync(); var orderResponse = await client.GetAsync($"/api/commerce/orders/{order.OrderNo}"); var paidOrder = await orderResponse.Content.ReadFromJsonAsync(); 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(); var secondPayment = await second.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); Assert.Equal(firstPayment!.Id, secondPayment!.Id); } private static async Task 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())!; } private static async Task 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 { 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 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 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 ParsePaymentNotificationAsync( string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } } }