Files
2026-10-23 18:16:05 +08:00

195 lines
9.3 KiB
C#

using System.ComponentModel.DataAnnotations;
using MAX.Framework.Commons;
using Store.Users.Contracts.Dtos;
using Store.Users.Entities;
namespace Store.Users.Tests;
public sealed class UserFundsTests
{
private static UserFundsRequest Request(string operation = "充值", decimal amount = 100m, string target = "main", string id = "request-1") =>
new() { UserName = target, RequestId = id, Amount = amount, Operation = operation, Remark = "测试操作" };
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Recharge_UpdatesFundsAndTotals_AndWritesAuditRecord(bool points)
{
using var context = new UserTestContext();
var response = await context.Funds.Change(Request(), points, default);
Assert.Equal(100m, response.Value);
var user = await context.Read("main");
if (points)
{
Assert.Equal(100m, user.Points);
Assert.Equal(100m, user.RechargePointsTotal);
var record = Assert.Single(await context.Db.Select<UserPointsRecordEntity>().ToListAsync());
Assert.Equal("充值", record.OptionType);
Assert.Equal("admin", record.OperatorName);
Assert.Equal("管理员", record.OperatorType);
Assert.Equal(100m, record.NewPoints);
Assert.Equal("user:request-1", record.OutTradeNo);
}
else
{
Assert.Equal(100m, user.Balance);
Assert.Equal(100m, user.RechargeAmountTotal);
var record = Assert.Single(await context.Db.Select<UserPaymentRecordEntity>().ToListAsync());
Assert.Equal(1, record.Type);
Assert.Equal("admin", record.CreateBy);
Assert.Equal(100m, record.Balance);
Assert.Equal("user:request-1", record.RefundFlowNo);
}
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RepeatedRequest_IsRejectedWithoutDuplicateFundsOrRecords(bool points)
{
using var context = new UserTestContext();
await context.Funds.Change(Request(), points, default);
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request(), points, default));
Assert.Contains("已处理", error.Message);
var user = await context.Read("main");
Assert.Equal(100m, points ? user.Points : user.Balance);
Assert.Equal(1, points ? await context.Db.Select<UserPointsRecordEntity>().CountAsync()
: await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
}
[Fact]
public async Task Transfer_MovesOwnPointsAndCreatesMatchingAuditPair()
{
using var context = new UserTestContext();
await context.Funds.Change(Request(), true, default);
context.CurrentUser.UserName = "main";
await context.Funds.Change(Request("转移", 30m, "child", "transfer"), true, default);
var sender = await context.Read("main");
var receiver = await context.Read("child");
Assert.Equal(70m, sender.Points);
Assert.Equal(30m, receiver.Points);
Assert.Equal(100m, sender.RechargePointsTotal);
Assert.Equal(30m, receiver.RechargePointsTotal);
var records = await context.Db.Select<UserPointsRecordEntity>().Where(r => r.OutTradeNo == "user:transfer").ToListAsync();
Assert.Equal(2, records.Count);
var debit = Assert.Single(records, r => r.OptionType == "转出");
var credit = Assert.Single(records, r => r.OptionType == "转入");
Assert.Equal(30m, debit.DeltaPoints);
Assert.Equal(debit.DeltaPoints, credit.DeltaPoints);
Assert.Equal("child", debit.CounterpartyAccountName);
Assert.Equal("main", credit.CounterpartyAccountName);
Assert.All(records, r => Assert.Equal("main", r.OperatorName));
}
[Fact]
public async Task TransferToSelf_IsRejectedWithoutRecords()
{
using var context = new UserTestContext();
context.CurrentUser.UserName = "main";
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request("转移"), true, default));
Assert.Contains("自己", error.Message);
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task InsufficientFunds_DoesNotChangeAccountsOrCreateRecords(bool points)
{
using var context = new UserTestContext();
if (points) context.CurrentUser.UserName = "main";
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request(points ? "转移" : "消费", target: points ? "child" : "main"), points, default));
Assert.Contains("不足", error.Message);
Assert.Equal(0m, (await context.Read("main")).Points);
Assert.Equal(0m, (await context.Read("other")).Points);
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
Assert.Equal(0, await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
}
[Fact]
public async Task BalanceConsumption_DoesNotReduceHistoricalRechargeTotal()
{
using var context = new UserTestContext();
await context.Funds.Change(Request(), false, default);
await context.Funds.Change(Request("消费", 25.5m, id: "consume"), false, default);
var user = await context.Read("main");
Assert.Equal(74.5m, user.Balance);
Assert.Equal(100m, user.RechargeAmountTotal);
var record = await context.Db.Select<UserPaymentRecordEntity>().Where(r => r.Type == 0).FirstAsync();
Assert.Equal(25.5m, record.Amount);
Assert.Equal(74.5m, record.Balance);
}
[Theory]
[InlineData(true, "充值")]
[InlineData(false, "充值")]
[InlineData(false, "消费")]
public async Task NonAdmin_CannotAdjustFunds(bool points, string operation)
{
using var context = new UserTestContext();
context.CurrentUser.UserName = "main";
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request(operation), points, default));
Assert.Equal(403, error.Code);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("0.001")]
[InlineData("1000001")]
public async Task InvalidAmount_IsRejectedBeforeWriting(string amount)
{
using var context = new UserTestContext();
await Assert.ThrowsAsync<ValidationException>(() => context.Funds.Change(Request(amount:
decimal.Parse(amount, System.Globalization.CultureInfo.InvariantCulture)), true, default));
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
}
[Theory]
[InlineData(true, "消费")]
[InlineData(false, "转移")]
public async Task UnsupportedOperation_IsRejected(bool points, string operation)
{
using var context = new UserTestContext();
var error = await Assert.ThrowsAsync<ValidationException>(() => context.Funds.Change(Request(operation), points, default));
Assert.Equal("不支持的资金操作类型", error.Message);
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
Assert.Equal(0, await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
}
[Fact]
public async Task UnknownOperation_IsRejectedByServiceValidation()
{
using var context = new UserTestContext();
var error = await Assert.ThrowsAsync<ValidationException>(() => context.Funds.Change(Request("续费"), true, default));
Assert.Equal("不支持的资金操作类型", error.Message);
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().CountAsync());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AuditWriteFailure_RollsBackAllChanges_AndAllowsRetryWithSameRequest(bool points)
{
using var context = new UserTestContext();
// 积分在第二条转账流水故障,余额在充值流水故障,均应回滚先前账户更新。
if (points)
{
await context.Funds.Change(Request(), true, default);
context.CurrentUser.UserName = "main";
}
var trigger = points
? "CREATE TRIGGER fail_audit BEFORE INSERT ON points WHEN NEW.option_type = '转入' BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END"
: "CREATE TRIGGER fail_audit BEFORE INSERT ON payments BEGIN SELECT RAISE(ABORT, 'forced audit failure'); END";
await context.Db.Ado.ExecuteNonQueryAsync(trigger);
var request = Request(points ? "转移" : "充值", 20m, "child", "retry");
var error = await Assert.ThrowsAnyAsync<Exception>(() => context.Funds.Change(request, points, default));
Assert.Contains("forced audit failure", error.Message);
Assert.Equal(0m, points ? (await context.Read("child")).Points : (await context.Read("child")).Balance);
Assert.Equal(0, await context.Db.Select<UserPointsRecordEntity>().Where(r => r.OutTradeNo == "user:retry").CountAsync());
Assert.Equal(0, await context.Db.Select<UserPaymentRecordEntity>().CountAsync());
if (points) Assert.Equal(100m, (await context.Read("main")).Points);
await context.Db.Ado.ExecuteNonQueryAsync("DROP TRIGGER fail_audit");
Assert.Equal(20m, (await context.Funds.Change(request, points, default)).Value);
}
}