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().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().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(() => 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().CountAsync() : await context.Db.Select().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().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(() => context.Funds.Change(Request("转移"), true, default)); Assert.Contains("自己", error.Message); Assert.Equal(0, await context.Db.Select().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(() => 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().CountAsync()); Assert.Equal(0, await context.Db.Select().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().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(() => 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(() => context.Funds.Change(Request(amount: decimal.Parse(amount, System.Globalization.CultureInfo.InvariantCulture)), true, default)); Assert.Equal(0, await context.Db.Select().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(() => context.Funds.Change(Request(operation), points, default)); Assert.Equal("不支持的资金操作类型", error.Message); Assert.Equal(0, await context.Db.Select().CountAsync()); Assert.Equal(0, await context.Db.Select().CountAsync()); } [Fact] public async Task UnknownOperation_IsRejectedByServiceValidation() { using var context = new UserTestContext(); var error = await Assert.ThrowsAsync(() => context.Funds.Change(Request("续费"), true, default)); Assert.Equal("不支持的资金操作类型", error.Message); Assert.Equal(0, await context.Db.Select().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(() => 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().Where(r => r.OutTradeNo == "user:retry").CountAsync()); Assert.Equal(0, await context.Db.Select().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); } }