forked from xiongyuxing/tiku-backend.net
293 lines
10 KiB
C#
293 lines
10 KiB
C#
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);
|
|
}
|