diff --git a/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs b/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs index 01b2cf8..a907b31 100644 --- a/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs +++ b/SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.Database.Enums; +using SVSim.Database.Models; using SVSim.Database.Repositories.Card; using SVSim.UnitTests.Infrastructure; @@ -311,4 +312,206 @@ public class CardInventoryRepositoryTests Assert.That(viewer.Currency.RedEther, Is.EqualTo(800UL)); Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(1)); } + + [Test] + public async Task Create_batch_charges_sum_and_grants_each_card() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 0, craftCost: 800); + await factory.SetRedEtherAsync(viewerId, 5_000UL); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.CreateCards(viewerId, new Dictionary + { + { 10001001L, 2 }, + { 10001002L, 1 }, + }); + + Assert.That(outcome.IsSuccess, Is.True); + // 2 * 200 + 1 * 800 = 1200 → 5000 - 1200 = 3800 + Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(3_800UL)); + + var grants = outcome.Result!.Grants; + Assert.That(grants.Any(g => g.RewardId == 10001001L && g.RewardNum == 2), Is.True); + Assert.That(grants.Any(g => g.RewardId == 10001002L && g.RewardNum == 1), Is.True); + } + + [Test] + public async Task Create_rejects_unknown_card_without_mutation() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SetRedEtherAsync(viewerId, 1_000UL); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.CreateCards(viewerId, new Dictionary { { 99_999_999L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(CreateError.UnknownCard)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(1_000UL), "no debit on rejection"); + } + + [Test] + public async Task Create_rejects_not_craftable_card_without_mutation() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // craftCost=0 mirrors IsNotCraftDestruct on basic/token cards + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 0, dustReward: 0); + await factory.SetRedEtherAsync(viewerId, 1_000UL); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.CreateCards(viewerId, new Dictionary { { 10001001L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(CreateError.NotCraftable)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(1_000UL)); + } + + [Test] + public async Task Create_rejects_when_would_exceed_max_copies() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // Viewer already owns 2 copies — crafting 2 more would push to 4, exceeding MaxCopies=3. + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, craftCost: 200); + await factory.SetRedEtherAsync(viewerId, 1_000UL); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.CreateCards(viewerId, new Dictionary { { 10001001L, 2 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(CreateError.WouldExceedMaxCopies)); + } + + [Test] + public async Task Create_at_boundary_2_to_3_succeeds() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, craftCost: 200); + await factory.SetRedEtherAsync(viewerId, 1_000UL); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + // 2 + 1 = 3 = MaxCopies — must succeed + var outcome = await repo.CreateCards(viewerId, new Dictionary { { 10001001L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.True); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(3)); + } + + [Test] + public async Task Create_rejects_insufficient_vials_without_mutation() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200); + await factory.SetRedEtherAsync(viewerId, 199UL); // one less than needed + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.CreateCards(viewerId, new Dictionary { { 10001001L, 1 } }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(CreateError.InsufficientVials)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(199UL), "no debit on rejection"); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(0), "no grant on rejection"); + } + + [Test] + public async Task Create_validates_full_batch_before_mutating() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200); + // Second card has count=3 already — adding any would exceed MaxCopies + await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 3, craftCost: 200); + await factory.SetRedEtherAsync(viewerId, 1_000UL); + + using var scope = factory.Services.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var outcome = await repo.CreateCards(viewerId, new Dictionary + { + { 10001001L, 1 }, // would-be valid + { 10001002L, 1 }, // would push to 4 — fails validation + }); + + Assert.That(outcome.IsSuccess, Is.False); + Assert.That(outcome.Error, Is.EqualTo(CreateError.WouldExceedMaxCopies)); + + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.Currency.RedEther, Is.EqualTo(1_000UL), "no debit when batch fails"); + Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(0), "valid card untouched"); + } + + [Test] + public async Task Create_first_time_owner_triggers_cosmetic_cascade() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + // Set up a card with a cosmetic-cascade row pointing at a Skin the viewer doesn't own. + // Use ids outside the seeded 10001001–10001003 range so the cascade can't accidentally + // pick up unrelated rows. + const long cardId = 999_003_010L; + const long skinId = 999_003_011L; + ctx.Cards.Add(new ShadowverseCardEntry + { + Id = cardId, Name = "CreateCascadeCard", Rarity = Rarity.Gold, + CollectionInfo = new CardCollectionInfo { CraftCost = 800, DustReward = 200 }, + }); + ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = (int)skinId, Name = "CreateCascadeSkin" }); + ctx.CardCosmeticRewards.Add(new CardCosmeticReward + { + CardId = cardId, Type = CosmeticType.Skin, CosmeticId = skinId, Quantity = 1, + }); + await ctx.SaveChangesAsync(); + + // Give the viewer enough RedEther in a separate scope so the helper's reset doesn't fire. + await factory.SetRedEtherAsync(viewerId, 1_000UL); + + var repo = scope.ServiceProvider.GetRequiredService(); + var outcome = await repo.CreateCards(viewerId, new Dictionary { { cardId, 1 } }); + + Assert.That(outcome.IsSuccess, Is.True); + + var grants = outcome.Result!.Grants; + // One Card grant + one Skin cascade grant + Assert.That(grants.Any(g => g.RewardType == (int)UserGoodsType.Card && g.RewardId == cardId), Is.True); + Assert.That(grants.Any(g => g.RewardType == (int)UserGoodsType.Skin && g.RewardId == skinId), Is.True); + + var viewer = await ctx.Viewers + .Include(v => v.LeaderSkins) + .FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.LeaderSkins.Any(s => s.Id == (int)skinId), Is.True, "cascade actually granted the skin"); + } }