Files
SVSimServer/SVSim.UnitTests/Services/CardAcquisitionServiceTests.cs
2026-05-25 16:34:24 -04:00

366 lines
18 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
private static async Task<long> SeedViewerWithCards(
SVSimTestFactory factory,
Dictionary<long, int> ownedCards,
IEnumerable<long>? grantableCardIds = null)
{
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ShadowverseCardEntry> 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<ICardAcquisitionService>();
}
[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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
// 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<SVSimDbContext>();
// 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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
// 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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
// 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");
}
}