using System.Net; using System.Net.Http.Json; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; using Tiku.Application.Points; using Tiku.Application.Security; using Tiku.Api.Options; 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 PointsEndpointTests { 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_points_request_returns_401() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/points/summary"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Non_member_cannot_access_points() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory, includeMembership: false); var sessionId = Guid.NewGuid(); await factory.SeedAsync(new AuthSession { Id = sessionId, TenantId = seed.TenantId, UserId = seed.UserId, TokenHash = "integration-test-token-hash", Provider = "test", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) }); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new( "Bearer", CreateToken([ new Claim(TikuClaimTypes.UserId, seed.UserId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString()), new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) ])); var response = await client.GetAsync("/api/points/summary"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Student_can_claim_task_idempotently_and_query_summary() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); var sourceId = Guid.NewGuid(); var first = await client.PostAsJsonAsync( "/api/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "daily_login", SourceType = "daily", SourceId = sourceId }); var second = await client.PostAsJsonAsync( "/api/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "daily_login", SourceType = "daily", SourceId = sourceId }); var firstClaim = await first.Content.ReadFromJsonAsync(); var secondClaim = await second.Content.ReadFromJsonAsync(); var summary = await (await client.GetAsync("/api/points/summary")) .Content .ReadFromJsonAsync(); var tasks = await (await client.GetAsync("/api/points/tasks")) .Content .ReadFromJsonAsync>(); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, second.StatusCode); Assert.Equal(firstClaim!.Id, secondClaim!.Id); Assert.Equal(20, summary!.EarnedPoints); Assert.Equal(20, summary.BalancePoints); Assert.Contains(tasks!.Items, item => item.TaskKey == "daily_login" && !item.CanClaim); } [Fact] public async Task Exchange_requires_enough_points() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); var response = await client.PostAsJsonAsync( "/api/points/exchange-orders", new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId }); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); } [Fact] public async Task Student_can_exchange_points_for_entitlement() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); await client.PostAsJsonAsync( "/api/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "practice_reward", SourceType = "practice", SourceId = Guid.NewGuid() }); var items = await (await client.GetAsync("/api/points/exchange-items")) .Content .ReadFromJsonAsync>(); var response = await client.PostAsJsonAsync( "/api/points/exchange-orders", new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId }); var order = await response.Content.ReadFromJsonAsync(); var orders = await (await client.GetAsync("/api/points/exchange-orders")) .Content .ReadFromJsonAsync>(); var summary = await (await client.GetAsync("/api/points/summary")) .Content .ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(items!.Items.Single(item => item.Id == seed.ExchangeItemId).CanExchange); Assert.Equal("Completed", order!.Status); Assert.Contains(orders!.Items, item => item.Id == order.Id); Assert.Equal(100, summary!.EarnedPoints); Assert.Equal(30, summary.SpentPoints); Assert.Equal(70, summary.BalancePoints); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Contains(dbContext.Entitlements, item => item.TenantId == seed.TenantId && item.UserId == seed.UserId && item.SourceType == "point_exchange_order"); } private static async Task SeedLoginUserAsync( ApiTestFactory factory, bool includeMembership = true) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var exchangeItemId = Guid.NewGuid(); var phone = "13800000001"; var passwordHash = new PasswordHasher().Hash("passw0rd!"); var entities = new List { new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Points Tenant" }, new User { Id = userId, Phone = phone, Name = "Points User" }, new UserIdentity { UserId = userId, Provider = "password", ProviderSubject = phone, Phone = phone, SecretPayload = CreateSecretPayload(passwordHash) }, new PointActivityTask { TenantId = tenantId, TaskKey = "daily_login", Title = "每日登录", TaskType = PointActivityTaskType.DailyLogin, Points = 20, MaxClaimsPerUser = 1, SortOrder = 1 }, new PointActivityTask { TenantId = tenantId, TaskKey = "practice_reward", Title = "练习奖励", TaskType = PointActivityTaskType.Practice, Points = 100, MaxClaimsPerUser = 3, SortOrder = 2 }, new PointExchangeItem { Id = exchangeItemId, TenantId = tenantId, ItemKey = "svip_7d", Name = "SVIP 7 天", ItemType = PointExchangeItemType.Entitlement, PointsCost = 30, Days = 7, Stock = 5, SortOrder = 1 } }; if (includeMembership) { entities.Add(new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active }); } await factory.SeedAsync(entities.ToArray()); return new PointSeed(tenantId, userId, exchangeItemId, phone); } private static async Task LoginAsync(HttpClient client, PointSeed 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 PointSeed(Guid TenantId, Guid UserId, Guid ExchangeItemId, string Phone); }