using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Points; using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class PointsEndpointTests { [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/student/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, 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 AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, seed.UserId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, seed.TenantId.ToString()) ])); var response = await client.GetAsync("/api/student/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/student/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "daily_login", SourceType = "daily", SourceId = sourceId }); var second = await client.PostAsJsonAsync( "/api/student/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/student/points/summary")) .Content .ReadFromJsonAsync(); var tasks = await (await client.GetAsync("/api/student/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/student/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/student/points/tasks/claim", new ClaimPointTaskDto { TaskKey = "practice_reward", SourceType = "practice", SourceId = Guid.NewGuid() }); var items = await (await client.GetAsync("/api/student/points/exchange-items")) .Content .ReadFromJsonAsync>(); var response = await client.PostAsJsonAsync( "/api/student/points/exchange-orders", new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId }); var order = await response.Content.ReadFromJsonAsync(); var orders = await (await client.GetAsync("/api/student/points/exchange-orders")) .Content .ReadFromJsonAsync>(); var summary = await (await client.GetAsync("/api/student/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.CreateSystemScope(); 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 entities = new List { new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Points Tenant" }, new User { Id = userId, Phone = phone, Name = "Points User" }.WithTestPassword(), 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) { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private sealed record PointSeed(Guid TenantId, Guid UserId, Guid ExchangeItemId, string Phone); }