195 lines
9.3 KiB
C#
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, "other", "transfer"), true, default);
|
|
var sender = await context.Read("main");
|
|
var receiver = await context.Read("other");
|
|
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("other", 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 = "other";
|
|
var error = await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(Request(points ? "转移" : "消费"), 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, "other", "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("other")).Points : (await context.Read("other")).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);
|
|
}
|
|
}
|