From fb1e6829b77d731d24803e6419353fcf2cbd6944 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Jun 2026 14:37:50 -0400 Subject: [PATCH] refactor(inventory): consolidate IsCurrency, skip num=0 grants in history - Drop IsWalletCurrency (duplicate of IsCurrency); use IsCurrency in WriteAcquireHistory. - Add comment on first SaveChangesAsync in CommitAsync explaining the two-phase flush. - Guard WriteAcquireHistory loop with grant.Num == 0 check so synthetic DebitItem post-state ops do not produce history rows. - Add InventoryHistoryTests.Commit_writes_no_history_row_for_item_debit to lock in the fix. Co-Authored-By: Claude Sonnet 4.6 --- .../Inventory/InventoryTransaction.cs | 10 +++----- .../Inventory/InventoryHistoryTests.cs | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index 8c2dc04..f49a1fb 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -276,6 +276,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction { ThrowIfCommitted(); + // Flush entity mutations first so audit-history rows are staged on top of post-commit state. await _db.SaveChangesAsync(ct); WriteAcquireHistory(); @@ -298,10 +299,11 @@ internal sealed class InventoryTransaction : IInventoryTransaction foreach (var op in _ops) { if (op is not GrantOp grant) continue; + if (grant.Num == 0) continue; // skip synthetic post-state grants (e.g. DebitItem) var rowSource = grant.IsCascade ? GrantSource.CardCosmeticCascade : _source; var rowMessage = grant.IsCascade ? cascadeMessage : primaryMessage; - var detailId = IsWalletCurrency(grant.Type) ? 0L : grant.DetailId; + var detailId = IsCurrency(grant.Type) ? 0L : grant.DetailId; _db.ViewerAcquireHistory.Add(new ViewerAcquireHistoryEntry { @@ -316,12 +318,6 @@ internal sealed class InventoryTransaction : IInventoryTransaction } } - private static bool IsWalletCurrency(UserGoodsType type) => type - is UserGoodsType.Crystal - or UserGoodsType.Rupy - or UserGoodsType.RedEther - or UserGoodsType.SpotCardPoint; - private IReadOnlyList BuildRewardList() { // Pass 1 — for each currency type, find the last op (spend OR grant) that touched it diff --git a/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs index 9d0f93f..cadc249 100644 --- a/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs +++ b/SVSim.UnitTests/Services/Inventory/InventoryHistoryTests.cs @@ -195,6 +195,31 @@ public class InventoryHistoryTests Assert.That(rows[1].Message, Is.EqualTo("Card cosmetic")); } + [Test] + public async Task Commit_writes_no_history_row_for_item_debit() + { + using var factory = new SVSim.UnitTests.Infrastructure.SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + // Seed an item the viewer owns so DebitItem has something to spend. + const int itemId = 5550001; + await factory.SeedOwnedItemAsync(viewerId, itemId, 5); + + using var scope = factory.Services.CreateScope(); + var inv = scope.ServiceProvider.GetRequiredService(); + await using (var tx = await inv.BeginAsync(viewerId, configure: c => c.Source = GrantSource.ItemPurchase)) + { + await tx.TryDebitAsync(SVSim.Database.Enums.UserGoodsType.Item, itemId, 1); + await tx.CommitAsync(); + } + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var rows = await ctx2.ViewerAcquireHistory.AsNoTracking() + .Where(h => h.ViewerId == viewerId).ToListAsync(); + Assert.That(rows, Is.Empty, "item debit should not produce a history row"); + } + [Test] public async Task Commit_zero_pads_detail_id_for_wallet_currencies() {