using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Repositories.Card; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Repositories; /// /// Coverage for CardInventoryRepository.DestructCards. Exercises the validate→mutate /// loop directly so tests don't need to round-trip through HTTP; the controller-level wire /// behavior is covered separately in CardControllerTests. /// public class CardInventoryRepositoryTests { [Test] public async Task Destruct_single_card_decrements_count_and_awards_vials() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); Assert.That(outcome.IsSuccess, Is.True, outcome.Error?.ToString()); Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(50UL)); Assert.That(outcome.Result!.NewOwnedCounts[10001001L], Is.EqualTo(4)); // Verify persisted state matches what was returned 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(50UL)); Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(4)); } [Test] public async Task Destruct_batch_decrements_each_card_and_sums_vials() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 2, dustReward: 200); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 2 }, { 10001002L, 1 }, }); Assert.That(outcome.IsSuccess, Is.True); // 2 * 50 + 1 * 200 = 300 Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(300UL)); Assert.That(outcome.Result!.NewOwnedCounts[10001001L], Is.EqualTo(1)); Assert.That(outcome.Result!.NewOwnedCounts[10001002L], Is.EqualTo(1)); } [Test] public async Task Destruct_leaves_zero_count_row_after_destructing_last_copy() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); // We just verify the OwnedCardEntry row survives a destruct-to-zero, so future operations (re-protect, re-craft) can attach to it. await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 1, dustReward: 50); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(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); var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == 10001001L); Assert.That(owned, Is.Not.Null, "OwnedCardEntry row should remain after destruct-to-zero"); Assert.That(owned!.Count, Is.EqualTo(0)); } [Test] public async Task Destruct_rejects_unknown_card_without_mutation() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(viewerId, new Dictionary { { 99_999_999L, 1 } }); Assert.That(outcome.IsSuccess, Is.False); Assert.That(outcome.Error, Is.EqualTo(DestructError.UnknownCard)); 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(0UL)); Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(5)); } [Test] public async Task Destruct_rejects_insufficient_count_without_mutation() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, dustReward: 50); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 3 } }); Assert.That(outcome.IsSuccess, Is.False); Assert.That(outcome.Error, Is.EqualTo(DestructError.InsufficientCards)); 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(0UL)); Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2)); } [Test] public async Task Destruct_rejects_protected_card_without_mutation() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50, isProtected: true); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); Assert.That(outcome.IsSuccess, Is.False); Assert.That(outcome.Error, Is.EqualTo(DestructError.CardProtected)); 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(0UL)); Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(3)); } [Test] public async Task Destruct_rejects_non_destructible_card_without_mutation() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); // dustReward=0 marks a card as IsNotCraftDestruct (e.g. tokens, basics) await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 0, craftCost: 0); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); Assert.That(outcome.IsSuccess, Is.False); Assert.That(outcome.Error, Is.EqualTo(DestructError.NotDestructible)); 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 Destruct_validates_full_batch_before_mutating() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 3, dustReward: 200, isProtected: true); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 2 }, // would-be valid { 10001002L, 1 }, // protected — fails validation }); Assert.That(outcome.IsSuccess, Is.False); Assert.That(outcome.Error, Is.EqualTo(DestructError.CardProtected)); // Critical: the valid card must be untouched. Proves validation runs against the full // batch before any inventory write. 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(0UL), "no vials awarded when batch fails"); Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(5)); Assert.That(viewer.Cards.First(c => c.Card.Id == 10001002L).Count, Is.EqualTo(3)); } [Test] public async Task Destruct_strips_excess_copies_from_a_deck() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50); await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); await factory.AddCardToDeckAsync(viewerId, Format.Rotation, deckNumber: 1, cardId: 10001001L, count: 3); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); // Destruct 2 — owned drops from 3 to 1 — deck must lose 2 of the 3 copies it had. var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 2 } }); Assert.That(outcome.IsSuccess, Is.True); var db = scope.ServiceProvider.GetRequiredService(); var deck = await db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Decks) .Include(d => d.Cards).ThenInclude(c => c.Card) .FirstAsync(d => d.Format == Format.Rotation && d.Number == 1); var deckCard = deck.Cards.First(c => c.Card.Id == 10001001L); Assert.That(deckCard.Count, Is.EqualTo(1), "deck should now hold only 1 copy of the card"); } [Test] public async Task Destruct_strips_excess_across_multiple_decks() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50); await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 2); await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 1, cardId: 10001001L, count: 3); await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 2, cardId: 10001001L, count: 3); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); // Destruct 1 — owned drops from 3 to 2 — each deck must lose 1 copy. var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 1 } }); Assert.That(outcome.IsSuccess, Is.True); var db = scope.ServiceProvider.GetRequiredService(); var decks = await db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Decks) .Include(d => d.Cards).ThenInclude(c => c.Card) .Where(d => d.Format == Format.Rotation) .ToListAsync(); foreach (var deck in decks) { Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2), $"deck {deck.Number} should now hold 2 copies"); } } [Test] public async Task Destruct_leaves_deck_untouched_when_owned_still_covers() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50); await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1); await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 1, cardId: 10001001L, count: 2); using var scope = factory.Services.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); // Destruct 2 — owned drops from 5 to 3 — deck still uses only 2, no strip needed. var outcome = await repo.DestructCards(viewerId, new Dictionary { { 10001001L, 2 } }); Assert.That(outcome.IsSuccess, Is.True); var db = scope.ServiceProvider.GetRequiredService(); var deck = await db.Viewers .Where(v => v.Id == viewerId) .SelectMany(v => v.Decks) .Include(d => d.Cards).ThenInclude(c => c.Card) .FirstAsync(d => d.Format == Format.Rotation && d.Number == 1); Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2), "deck untouched because owned (3) still covers usage (2)"); } }