using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Services; public class CardAcquisitionServiceTests { /// /// Seeds a viewer (via the factory's real RegisterViewer-backed helper) and gives it the /// given owned cards (key = card_id, value = count). Card rows are created on-demand if /// the test's card_id isn't already in the minimal seeded card set (matches the pattern /// used by SVSimTestFactory.SeedOwnedCardAsync, but inlined so multiple cards can be /// seeded in one viewer in one call). Returns the viewer's Id. /// private static async Task SeedViewerWithCards( SVSimTestFactory factory, Dictionary ownedCards, IEnumerable? grantableCardIds = null) { long viewerId = await factory.SeedViewerAsync(); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) .FirstAsync(v => v.Id == viewerId); foreach (var (cardId, count) in ownedCards) { var card = await EnsureCardAsync(db, cardId); viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = false }); } // Pre-seed bare Cards rows (no ownership) for any cardIds the test plans to grant via // the service. RewardGrantService.ApplyAsync does FirstOrDefaultAsync on _db.Cards; // without the row the grant throws InvalidOperationException("Card {id} not in catalog"). if (grantableCardIds is not null) { foreach (var cardId in grantableCardIds) { await EnsureCardAsync(db, cardId); } } await db.SaveChangesAsync(); return viewerId; } private static async Task EnsureCardAsync(SVSimDbContext db, long cardId) { var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId); if (card is null) { // Foil twins follow the universal +1 convention (card_id ends in 1). Marking // IsFoil here keeps test setup tidy so foil-resolution tests don't have to // hand-patch the card row. var isFoil = cardId % 10 == 1; card = new ShadowverseCardEntry { Id = cardId, Name = $"SeededCard{cardId}", Rarity = Database.Enums.Rarity.Bronze, IsFoil = isFoil }; db.Cards.Add(card); await db.SaveChangesAsync(); } return card; } private static ICardAcquisitionService GetService(SVSimTestFactory factory) { var scope = factory.Services.CreateScope(); return scope.ServiceProvider.GetRequiredService(); } [Test] public async Task GrantManyAsync_NewBronzeCard_GrantsCardOnly() { // 101111010 is a synthetic test card (inserted ad-hoc via grantableCardIds) with no // CardCosmeticReward associations. Expectation: grant returns only the type=5 entry. using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 101111010L }); var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 101111010L }); Assert.That(result.RewardList, Has.Count.EqualTo(1)); Assert.That(result.RewardList[0].RewardType, Is.EqualTo(5)); // Card Assert.That(result.RewardList[0].RewardId, Is.EqualTo(101111010L)); Assert.That(result.RewardList[0].RewardNum, Is.EqualTo(1)); // post-state count } [Test] public async Task GrantManyAsync_LeaderCard_GrantsCardAndSkin() { // Card 704741010 (Aria leader-card variant) has 3 cosmetic associations in the seed: // skin 407, sleeve 704741010, emblem 704741010. using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); // Since SqliteFriendlyModelCustomizer strips CardCosmeticReward seed in tests, insert // the specific mappings we need for this test. using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.CardCosmeticRewards.AddRange( new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }, new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Sleeve, CosmeticId = 704741010L, Quantity = 1 }, new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Emblem, CosmeticId = 704741010L, Quantity = 1 } ); // Ensure master rows exist for the cosmetics we'll grant if (await db.LeaderSkins.FindAsync(407) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); if (await db.Sleeves.FindAsync(704741010) is null) db.Sleeves.Add(new SleeveEntry { Id = 704741010 }); if (await db.Emblems.FindAsync(704741010) is null) db.Emblems.Add(new EmblemEntry { Id = 704741010 }); await db.SaveChangesAsync(); } var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10); Assert.That(skinEntry, Is.Not.Null, "expected a Skin reward entry"); Assert.That(skinEntry!.RewardId, Is.EqualTo(407L)); // Verify viewer ownership was actually written to DB using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers .Include(v => v.LeaderSkins) .FirstAsync(v => v.Id == viewerId); Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True); } } [Test] public async Task GrantManyAsync_AlreadyOwnedSkin_OmitsFromRewardList() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); // Pre-grant the skin to this viewer var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); var skin = await db.LeaderSkins.FindAsync(407) ?? db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }).Entity; if (!viewer.LeaderSkins.Any(s => s.Id == 407)) viewer.LeaderSkins.Add(skin); // Seed the card→skin mapping db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); await db.SaveChangesAsync(); } var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False, "skin entry should be omitted since viewer already owns it"); Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True, "card grant entry should still be emitted"); } [Test] public async Task GrantManyAsync_FoilLeaderCard_ResolvesToNonFoilCosmetics() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741011L }); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); // CardCosmeticReward.CardId has a FK→Cards.Id; ensure the non-foil row exists // even though we never grant it directly (the foil twin is the granted card). await EnsureCardAsync(db, 704741010L); // Map cosmetics to the NON-FOIL card_id (704741010), as the seed convention requires db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); if (await db.LeaderSkins.FindAsync(407) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); await db.SaveChangesAsync(); } var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 704741011L }); var skinEntry = result.RewardList.SingleOrDefault(r => r.RewardType == 10); Assert.That(skinEntry, Is.Not.Null, "expected skin entry via foil resolution"); Assert.That(skinEntry!.RewardId, Is.EqualTo(407L)); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); Assert.That(viewer.Cards.Any(c => c.Card.Id == 704741011L), Is.True, "card is the foil"); Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True); } [Test] public async Task GrantManyAsync_MultipleCopiesOfSameLeader_GrantsCosmeticOnce() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); if (await db.LeaderSkins.FindAsync(407) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); await db.SaveChangesAsync(); } var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 704741010L, 704741010L, 704741010L }); Assert.That(result.RewardList.Count(r => r.RewardType == 10), Is.EqualTo(1), "skin should appear exactly once in reward_list"); var cardEntry = result.RewardList.Single(r => r.RewardType == 5 && r.RewardId == 704741010L); Assert.That(cardEntry.RewardNum, Is.EqualTo(3), "card count should reflect all 3 copies"); } [Test] public async Task GrantManyAsync_RecentLeaderCard_GrantsAllFiveCosmeticTypes() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 721141010L }); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); // All 5 cosmetic types for this card. Exact ids: from data_dumps captures. db.CardCosmeticRewards.AddRange( new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Sleeve, CosmeticId = 721141010L, Quantity = 1 }, new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Emblem, CosmeticId = 721141010L, Quantity = 1 }, new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Degree, CosmeticId = 120021L, Quantity = 1 }, new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.Skin, CosmeticId = 4601L, Quantity = 1 }, new CardCosmeticReward { CardId = 721141010L, Type = CosmeticType.MyPageBG, CosmeticId = 721141010L, Quantity = 1 } ); // Ensure master rows if (await db.LeaderSkins.FindAsync(4601) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = 4601, Name = "TestSkin4601" }); if (await db.Sleeves.FindAsync(721141010) is null) db.Sleeves.Add(new SleeveEntry { Id = 721141010 }); if (await db.Emblems.FindAsync(721141010) is null) db.Emblems.Add(new EmblemEntry { Id = 721141010 }); if (await db.Degrees.FindAsync(120021) is null) db.Degrees.Add(new DegreeEntry { Id = 120021 }); if (await db.MyPageBackgrounds.FindAsync(721141010) is null) db.MyPageBackgrounds.Add(new MyPageBackgroundEntry { Id = 721141010 }); await db.SaveChangesAsync(); } var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 721141010L }); Assert.Multiple(() => { Assert.That(result.RewardList.Any(r => r.RewardType == 6), Is.True, "Sleeve"); Assert.That(result.RewardList.Any(r => r.RewardType == 7), Is.True, "Emblem"); Assert.That(result.RewardList.Any(r => r.RewardType == 8), Is.True, "Degree"); Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.True, "Skin"); Assert.That(result.RewardList.Any(r => r.RewardType == 15), Is.True, "MyPageBG"); }); } [Test] public async Task BackfillCosmeticsAsync_DoesNotIncrementCardCount() { using var factory = new SVSimTestFactory(); // Pre-seed viewer with card 704741010 count=5, no skin var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 5 }); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); if (await db.LeaderSkins.FindAsync(407) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); await db.SaveChangesAsync(); } var service = GetService(factory); var result = await service.BackfillCosmeticsAsync(viewerId); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); var viewer = await db2.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); var owned = viewer.Cards.Single(c => c.Card.Id == 704741010L); Assert.That(owned.Count, Is.EqualTo(5), "card count should be unchanged in backfill mode"); Assert.That(viewer.LeaderSkins.Any(s => s.Id == 407), Is.True, "skin should be backfilled"); Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True, "skin entry returned even in backfill mode"); Assert.That(result.RewardList.Any(r => r.RewardType == 5), Is.False, "no type=5 card entries in backfill mode"); } [Test] public async Task BackfillCosmeticsAsync_CalledTwice_SecondCallIsNoOp() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { [704741010L] = 1 }); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); if (await db.LeaderSkins.FindAsync(407) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); await db.SaveChangesAsync(); } var service = GetService(factory); var first = await service.BackfillCosmeticsAsync(viewerId); var second = await service.BackfillCosmeticsAsync(viewerId); Assert.That(first.RewardList, Is.Not.Empty, "first call should grant cosmetics"); Assert.That(second.RewardList, Is.Empty, "second call should be a no-op"); } [Test] public async Task GrantManyAsync_LeaderCardWithMissingMapping_GrantsCardSilently() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 701141010L }); // NO CardCosmeticReward rows inserted for this card — simulates the 83 missing-mapping cases. var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 701141010L }); Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 701141010L), Is.True); Assert.That(result.RewardList.Any(r => r.RewardType == 10), Is.False); // No exception means it handled the missing mapping gracefully. } [Test] public async Task GrantManyAsync_OrphanCosmeticReward_LogsWarningAndSkips() { using var factory = new SVSimTestFactory(); var viewerId = await SeedViewerWithCards(factory, new() { }, grantableCardIds: new[] { 704741010L }); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); // Real skin association db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 407L, Quantity = 1 }); if (await db.LeaderSkins.FindAsync(407) is null) db.LeaderSkins.Add(new LeaderSkinEntry { Id = 407, Name = "TestSkin407" }); // ORPHAN: points to non-existent skin_id db.CardCosmeticRewards.Add(new CardCosmeticReward { CardId = 704741010L, Type = CosmeticType.Skin, CosmeticId = 9999999L, Quantity = 1 }); await db.SaveChangesAsync(); } var service = GetService(factory); var result = await service.GrantManyAsync(viewerId, new[] { 704741010L }); Assert.That(result.RewardList.Any(r => r.RewardType == 5 && r.RewardId == 704741010L), Is.True); Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 407L), Is.True, "real skin should still be granted"); Assert.That(result.RewardList.Any(r => r.RewardType == 10 && r.RewardId == 9999999L), Is.False, "orphan cosmetic should not appear in reward_list"); } }