forked from xiongyuxing/tiku-backend.net
feat: add student points endpoints
This commit is contained in:
292
Tiku.IntegrationTests/Api/PointsEndpointTests.cs
Normal file
292
Tiku.IntegrationTests/Api/PointsEndpointTests.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
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<PointClaimItem>();
|
||||
var secondClaim = await second.Content.ReadFromJsonAsync<PointClaimItem>();
|
||||
var summary = await (await client.GetAsync("/api/points/summary"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<PointSummaryItem>();
|
||||
var tasks = await (await client.GetAsync("/api/points/tasks"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<PointList<PointTaskItem>>();
|
||||
|
||||
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<PointList<PointExchangeItemDto>>();
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/points/exchange-orders",
|
||||
new CreatePointExchangeOrderDto { ItemId = seed.ExchangeItemId });
|
||||
var order = await response.Content.ReadFromJsonAsync<PointExchangeOrderItem>();
|
||||
var orders = await (await client.GetAsync("/api/points/exchange-orders"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<PointList<PointExchangeOrderItem>>();
|
||||
var summary = await (await client.GetAsync("/api/points/summary"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<PointSummaryItem>();
|
||||
|
||||
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<TikuDbContext>();
|
||||
Assert.Contains(dbContext.Entitlements, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.UserId == seed.UserId &&
|
||||
item.SourceType == "point_exchange_order");
|
||||
}
|
||||
|
||||
private static async Task<PointSeed> 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<object>
|
||||
{
|
||||
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<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 PointSeed(Guid TenantId, Guid UserId, Guid ExchangeItemId, string Phone);
|
||||
}
|
||||
Reference in New Issue
Block a user