Additional card content

This commit is contained in:
gamer147
2026-05-24 17:07:05 -04:00
parent 12fb2f4801
commit 34bcc579a5
18 changed files with 53025 additions and 16 deletions

View File

@@ -1,6 +1,11 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
@@ -295,4 +300,66 @@ public class LoadControllerTests
Assert.That(root.TryGetProperty("battle_pass_level_info", out _), Is.False,
"battle_pass_level_info optional per spec; emit null until viewer pass state is wired");
}
[Test]
public async Task LoadIndex_GrantsMissingCosmeticsForOwnedCards()
{
// Verifies the C.2 wiring: /load/index invokes ICardAcquisitionService in backfill mode,
// so a viewer who already owns a leader card but lacks its associated cosmetics gets
// them granted on the next load. Mirrors the in-flight migration story for existing
// accounts that pre-date the cosmetic-grant feature.
using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync();
// Seed viewer with leader card 704741010 (count=1), seed mapping → skin 407, seed
// master skin row. Viewer does NOT yet own the skin.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.Include(v => v.Cards).FirstAsync(v => v.Id == viewerId);
var card = await db.Cards.FindAsync(704741010L);
if (card is null)
{
card = new ShadowverseCardEntry { Id = 704741010L, Name = "TestLeader", Rarity = Rarity.Legendary, IsFoil = false };
db.Cards.Add(card);
}
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 1, IsProtected = false });
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();
}
// Call /load/index — backfill should fire as part of the action.
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/load/index",
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
// Verify the response payload includes the backfilled cosmetic (not just the DB state).
// This guards against a regression where the controller serves a stale viewer snapshot
// (GetViewerByShortUdid uses .AsNoTracking() so the in-memory `viewer` reference does
// not see writes the service makes on its own tracked instance — without a post-grant
// re-fetch the first /load/index would report the skin as un-owned even though the DB
// had been updated). user_leader_skin_list always carries all master skins; the per-entry
// is_owned flag is the actual ownership signal.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var skin407 = doc.RootElement.GetProperty("user_leader_skin_list").EnumerateArray()
.FirstOrDefault(e => e.GetProperty("leader_skin_id").GetInt32() == 407);
Assert.That(skin407.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
"response payload should include leader skin 407 entry");
Assert.That(skin407.GetProperty("is_owned").GetBoolean(), Is.True,
"response payload should mark backfilled skin 407 as owned, not just DB state");
// Verify skin 407 was actually granted by re-reading viewer state.
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,
"skin 407 should have been backfilled by /load/index");
}
}
}

View File

@@ -354,4 +354,114 @@ public class PackControllerOpenTests
Assert.That(cardEntryIds, Is.SupersetOf(drawnCardIds),
"Every unique drawn card_id must appear in reward_list with reward_type=5.");
}
[Test]
public async Task OpenPack_ResponseIncludesSkinEntry_WhenLeaderCardDrawn()
{
// Verifies the C.1 refactor surfaces cosmetic grants from ICardAcquisitionService into
// the /pack/open response. Seeds a pack whose pool contains ONLY a known leader card
// (so every slot is forced to that card via PickCardOfRarity's fallback walk), plus the
// CardCosmeticReward mapping + LeaderSkinEntry master row. Expectation: response's
// reward_list contains a type=10 (Skin) entry with the mapped skin_id.
const long LeaderCardId = 704741010L;
const int SkinId = 407;
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId);
await SeedCosmeticMapping(factory, LeaderCardId, SkinId);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Rupees = 200;
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":{{parentGachaId}},"gacha_id":{{parentGachaId * 100 + 2}},"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}""";
var response = await client.PostAsync("/pack/open", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var rewardList = doc.RootElement.GetProperty("reward_list");
var skinEntry = Enumerable.Range(0, rewardList.GetArrayLength())
.Select(i => rewardList[i])
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 10);
Assert.That(skinEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
"expected a Skin (type=10) entry in /pack/open's reward_list when a leader card is drawn");
Assert.That(skinEntry.GetProperty("reward_id").GetInt64(), Is.EqualTo((long)SkinId));
}
/// <summary>
/// Creates a new CardSet whose ONLY non-foil card is <paramref name="leaderCardId"/>, plus
/// a PackConfigEntry pointing at that set. PackOpenService's PickCardOfRarity fallback walks
/// every rarity and lands on the sole available card, so every draw from this pack is
/// guaranteed to produce <paramref name="leaderCardId"/>. Returns the parent gacha id.
/// </summary>
private static async Task<int> SeedSingleLeaderCardPack(SVSimTestFactory f, long leaderCardId)
{
const int CardSetId = 99001;
const int ParentGachaId = 99001;
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Reuse an existing card row if one exists at this id (e.g. seeded by globals); else
// insert a Legendary stub. Rarity=Legendary so SV Classic slot-8 (which forbids Bronze)
// resolves cleanly without falling through.
var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == leaderCardId);
if (card is null)
{
card = new ShadowverseCardEntry { Id = leaderCardId, Name = $"SeededLeader{leaderCardId}", Rarity = Rarity.Legendary };
db.Cards.Add(card);
await db.SaveChangesAsync();
}
// Attach to a dedicated CardSet so the pool resolver (PackCategory.None branch) sees
// exactly this one card.
var set = new ShadowverseCardSetEntry
{
Id = CardSetId, Name = "SingleLeaderTestSet",
IsInRotation = false, IsBasic = false,
Cards = new List<ShadowverseCardEntry> { card },
};
db.CardSets.Add(set);
db.Packs.Add(new PackConfigEntry
{
Id = ParentGachaId, BasePackId = CardSetId, PackCategory = PackCategory.None,
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
GachaType = 1, GachaDetail = "single-leader test", SleeveId = 3000099,
ChildGachas = {
new PackChildGachaEntry { GachaId = ParentGachaId * 100 + 2, TypeDetail = 7, Cost = 100, CardCount = 8 },
},
});
await db.SaveChangesAsync();
return ParentGachaId;
}
/// <summary>
/// Inserts a CardCosmeticReward(Skin) row mapping <paramref name="cardId"/> → <paramref name="skinId"/>,
/// and ensures the LeaderSkinEntry master row exists. Production seed for CardCosmeticReward
/// is stripped in tests by SqliteFriendlyModelCustomizer, so the test must insert its own.
/// </summary>
private static async Task SeedCosmeticMapping(SVSimTestFactory f, long cardId, int skinId)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
if (await db.LeaderSkins.FindAsync(skinId) is null)
db.LeaderSkins.Add(new LeaderSkinEntry { Id = skinId, Name = $"TestSkin{skinId}" });
db.CardCosmeticRewards.Add(new CardCosmeticReward
{
CardId = cardId, Type = CosmeticType.Skin, CosmeticId = skinId, Quantity = 1,
});
await db.SaveChangesAsync();
}
}

View File

@@ -30,6 +30,34 @@ internal class SqliteFriendlyModelCustomizer : ModelCustomizer
shortUdidProperty.ValueGenerated = ValueGenerated.Never;
AssignClientSideKeyGenerators(modelBuilder.Model);
StripCardCosmeticRewardSeed(modelBuilder.Model);
}
/// <summary>
/// CardCosmeticReward rows have an FK to Cards.Id, and the production model HasData-seeds
/// 1068 rows from card_cosmetic_rewards.csv (chunk A.2). In production those rows have
/// matching cards inserted by the CardImporter before runtime. The unit-test factory uses
/// SQLite + EnsureCreated + a minimal 3-card seed — most of the cosmetic-reward rows have
/// no matching Cards row, and EnsureCreated's FK-deferred batch insert throws SqliteException
/// "FOREIGN KEY constraint failed" at host construction time. Strip the seed in tests; the
/// test fixture inserts CardCosmeticReward rows ad-hoc when a specific scenario needs them.
///
/// HasData seed is stored in the internal EntityType._data field (no public API). Clear it
/// via reflection. The clear runs after base.Customize so the HasData call inside Seed()
/// has populated the list before we wipe it.
/// </summary>
private static void StripCardCosmeticRewardSeed(IMutableModel model)
{
var entityType = model.FindEntityType(typeof(CardCosmeticReward));
if (entityType is null) return;
// EntityType._data is a List<IDictionary<string, object>>? — null when empty.
var dataField = entityType.GetType().GetField(
"_data",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (dataField is null) return;
var list = dataField.GetValue(entityType) as System.Collections.IList;
list?.Clear();
}
/// <summary>

View File

@@ -0,0 +1,365 @@
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. GrantCardsToViewer does a FirstAsync(c => c.Id == grpKey) lookup; without
// these the production code throws "Sequence contains no elements".
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 GrantAsync_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.GrantAsync(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 GrantAsync_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.GrantAsync(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 GrantAsync_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.GrantAsync(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 GrantAsync_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.GrantAsync(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 GrantAsync_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.GrantAsync(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 GrantAsync_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.GrantAsync(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 GrantAsync_BackfillMode_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.GrantAsync(viewerId, newCardIds: null);
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 GrantAsync_BackfillCalledTwice_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.GrantAsync(viewerId, newCardIds: null);
var second = await service.GrantAsync(viewerId, newCardIds: null);
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 GrantAsync_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.GrantAsync(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 GrantAsync_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.GrantAsync(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");
}
}