| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- using Application.Abstractions.Cache;
- using Application.Helpers;
- using Domain.Entities.Common;
- using Domain.Entities.Common.ValueObject;
- using Domain.Entities.Members;
- using Domain.Entities.Wallets;
- using Domain.Entities.Wallets.Policy;
- using Domain.Entities.Wallets.ValueObject;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Tests;
- [TestClass]
- public sealed class ActivityTokenGranterTests
- {
- // 캐시는 항상 miss → DB Config 폴백 경로를 검증한다.
- private sealed class NullCacheService : ICacheService
- {
- public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
- public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
- public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
- public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
- public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
- public Task<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
- }
- private static async Task SeedConfigAsync(Infrastructure.Persistence.AppDbContext db, int postToken, int commentToken, int dailyCap)
- {
- var config = Config.Create();
- config.UpdateActivityToken(new ActivityTokenConfig { PostToken = postToken, CommentToken = commentToken, DailyTokenCap = dailyCap });
- await db.Config.AddAsync(config);
- await db.SaveChangesAsync(default);
- }
- private static async Task<int> CreateMemberWithWalletAsync(Infrastructure.Persistence.AppDbContext db)
- {
- var member = Member.Create($"activity-{Guid.NewGuid():N}@antooza.test");
- await db.Member.AddAsync(member);
- await db.SaveChangesAsync(default);
- await db.Wallet.AddAsync(Wallet.Create(member.ID));
- await db.SaveChangesAsync(default);
- return member.ID;
- }
- [TestMethod]
- public async Task GrantForPost_CreditsRewardPartition_WithActivityRefID()
- {
- using var db = TestDb.Create();
- await SeedConfigAsync(db, postToken: 100, commentToken: 50, dailyCap: 0);
- var memberID = await CreateMemberWithWalletAsync(db);
- await ActivityTokenGranter.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 777, default);
- await db.SaveChangesAsync(default);
- var refID = ActivityTokenGranter.PostRefIDFor(777);
- using var verify = TestDb.Create();
- var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(100m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 토큰은 Reward 파티션에 적립되어야 한다");
- var tx = wallet.Transactions.Single(t => t.RefID == refID);
- Assert.AreEqual(WalletBalanceType.Reward, tx.BalanceType);
- Assert.AreEqual(WalletTransactionType.RewardEarned, tx.TxType);
- Assert.AreEqual(ActivityTokenGranter.PostReason, tx.Reason);
- }
- [TestMethod]
- public async Task GrantForComment_IsIdempotent_NoDoubleCredit()
- {
- using var db = TestDb.Create();
- await SeedConfigAsync(db, postToken: 0, commentToken: 30, dailyCap: 0);
- var memberID = await CreateMemberWithWalletAsync(db);
- await ActivityTokenGranter.GrantForCommentAsync(db, new NullCacheService(), memberID, commentID: 900, default);
- await db.SaveChangesAsync(default);
- // 동일 댓글 재호출 (신규 컨텍스트)
- using var db2 = TestDb.Create();
- await ActivityTokenGranter.GrantForCommentAsync(db2, new NullCacheService(), memberID, commentID: 900, default);
- await db2.SaveChangesAsync(default);
- var refID = ActivityTokenGranter.CommentRefIDFor(900);
- using var verify = TestDb.Create();
- var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(30m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "동일 댓글 재호출 시 이중 적립되지 않아야 한다");
- Assert.AreEqual(1, wallet.Transactions.Count(t => t.RefID == refID), "활동 토큰 원장은 정확히 1건이어야 한다");
- }
- [TestMethod]
- public async Task GrantForPost_ZeroConfiguredAmount_GrantsNothing()
- {
- using var db = TestDb.Create();
- await SeedConfigAsync(db, postToken: 0, commentToken: 50, dailyCap: 0);
- var memberID = await CreateMemberWithWalletAsync(db);
- await ActivityTokenGranter.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 1, default);
- await db.SaveChangesAsync(default);
- using var verify = TestDb.Create();
- var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 토큰 0 이면 미지급");
- Assert.AreEqual(0, wallet.Transactions.Count, "0 지급 시 원장 미기록");
- }
- [TestMethod]
- public async Task GrantForPost_DailyCap_ClampsSameDayGrants()
- {
- using var db = TestDb.Create();
- // 게시글당 100, 일일 상한 250 → 3번째 게시글은 50 으로 clamp, 4번째는 0(스킵)
- await SeedConfigAsync(db, postToken: 100, commentToken: 0, dailyCap: 250);
- var memberID = await CreateMemberWithWalletAsync(db);
- for (int postID = 1; postID <= 4; postID++)
- {
- using var ctx = TestDb.Create();
- await ActivityTokenGranter.GrantForPostAsync(ctx, new NullCacheService(), memberID, postID, default);
- await ctx.SaveChangesAsync(default);
- }
- using var verify = TestDb.Create();
- var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
- Assert.AreEqual(250m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "일일 상한(250)을 넘겨 적립되면 안 된다");
- // 100 + 100 + 50 = 3건 (4번째는 남은 한도 0 이라 스킵)
- Assert.AreEqual(3, wallet.Transactions.Count(t => t.TxType == WalletTransactionType.RewardEarned), "상한 도달 후에는 추가 지급되지 않아야 한다");
- }
- [TestMethod]
- public void StoreOrder_CannotSpendToken_CashOnlyPolicy()
- {
- // d4 M0: 상점 결제는 캐시(PgCharged/Deposit) 전용 — 토큰(Reward/Airdrop)은 사용 불가.
- var wallet = Wallet.Create(1);
- wallet.CreditReward(Money.KRW(1000), "REWARD"); // 토큰만 보유
- Assert.AreEqual(0m, wallet.GetStoreOrderAvailable().Value, "토큰만 있는 지갑의 상점 가용 잔액은 0 이어야 한다");
- Assert.ThrowsExactly<InvalidOperationException>(() => wallet.DebitForStoreOrder(Money.KRW(1)), "토큰으로는 상점 결제 차감이 불가해야 한다");
- // 정책 배열 자체가 캐시 2종만 포함하는지도 확인
- CollectionAssert.AreEqual(
- new[] { WalletBalanceType.PgCharged, WalletBalanceType.Deposit },
- SpendPolicy.StoreOrderSpendOrder);
- }
- [TestMethod]
- public void StoreOrder_SpendsCash_WhenCashPresent()
- {
- var wallet = Wallet.Create(1);
- wallet.CreditPgCharge(Money.KRW(500));
- Assert.AreEqual(500m, wallet.GetStoreOrderAvailable().Value);
- var splits = wallet.DebitForStoreOrder(Money.KRW(300));
- Assert.AreEqual(1, splits.Count);
- Assert.AreEqual(WalletBalanceType.PgCharged, splits[0].Type);
- Assert.AreEqual(300m, splits[0].Amount.Value);
- Assert.AreEqual(200m, wallet.GetBalance(WalletBalanceType.PgCharged).Value);
- }
- }
|