diff --git a/SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs b/SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs new file mode 100644 index 0000000..a34e0c4 --- /dev/null +++ b/SVSim.UnitTests/Wire/InventoryRewardListWireShape.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; +using SVSim.Database.Services.Inventory; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Wire; + +public class InventoryRewardListWireShape +{ + [Test] + public async Task Spend_crystal_plus_grant_card_with_cascade_matches_fixture() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + long cardId = await factory.SeedCardAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 1000; + const int sleeveId = 2_000_040_000; + ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId }); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = cardId, CosmeticId = sleeveId, Type = CosmeticType.Sleeve }); + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + await using var tx = await inv.BeginAsync(viewerId); + await tx.TrySpendAsync(SpendCurrency.Crystal, 500); + await tx.GrantAsync(UserGoodsType.Card, cardId, 3); + var result = await tx.CommitAsync(); + + var opts = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + var json = JsonSerializer.Serialize(result.RewardList, opts); + + // Expected order: currency entries in first-touch order, then non-currency in first-touch order. + // Crystal spend comes first (post-state 500), then Card grant (post-state count 3), then + // Sleeve cascade (always 1). + var doc = JsonDocument.Parse(json); + var arr = doc.RootElement.EnumerateArray().ToList(); + Assert.That(arr, Has.Count.EqualTo(3), $"Expected 3 reward entries, got {arr.Count}. JSON: {json}"); + + Assert.That(arr[0].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Crystal), + "First entry should be Crystal (spend post-state)"); + Assert.That(arr[0].GetProperty("reward_num").GetInt32(), Is.EqualTo(500), + "Crystal post-state after spending 500 from 1000 should be 500"); + + Assert.That(arr[1].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Card), + "Second entry should be Card"); + Assert.That(arr[1].GetProperty("reward_num").GetInt32(), Is.EqualTo(3), + "Card post-state count for fresh grant of 3 should be 3"); + + Assert.That(arr[2].GetProperty("reward_type").GetInt32(), Is.EqualTo((int)UserGoodsType.Sleeve), + "Third entry should be Sleeve (cascade from card grant)"); + Assert.That(arr[2].GetProperty("reward_id").GetInt32(), Is.EqualTo(sleeveId), + "Sleeve reward_id should match the seeded sleeve"); + + // Verify snake_case keys are present (not PascalCase) + Assert.That(arr[0].TryGetProperty("reward_type", out _), Is.True, "Key must be reward_type not RewardType"); + Assert.That(arr[0].TryGetProperty("reward_id", out _), Is.True, "Key must be reward_id not RewardId"); + Assert.That(arr[0].TryGetProperty("reward_num", out _), Is.True, "Key must be reward_num not RewardNum"); + } +}