Card liquefication

This commit is contained in:
gamer147
2026-05-24 14:42:44 -04:00
parent d9ef9fe1fc
commit 12fb2f4801
9 changed files with 862 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
using System.Net;
using System.Text;
using System.Text.Json;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// End-to-end coverage for POST /card/destruct. Exercises the controller's double-decode of
/// the JSON-string-in-JSON payload and the reward_list post-state-totals shape. The
/// validate/mutate logic itself is covered by <c>CardInventoryRepositoryTests</c>; the tests
/// here just confirm the wire contract.
/// </summary>
public class CardControllerTests
{
private static StringContent DestructBody(string innerJson) =>
new(
$$"""{"card_id_number_array":{{JsonSerializer.Serialize(innerJson)}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""",
Encoding.UTF8,
"application/json");
[Test]
public async Task Destruct_happy_path_returns_redether_and_card_post_totals()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Inner JSON: cardId -> "<num>,<client_snapshot>". The snapshot is informational only.
var response = await client.PostAsync("/card/destruct",
DestructBody("{\"10001001\":\"2,5\"}"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
// The ShadowverseTranslationMiddleware only fires for UnityPlayer UA requests; test
// clients send plain HTTP so the controller's JSON is returned unwrapped.
var rewardList = JsonDocument.Parse(body).RootElement
.GetProperty("reward_list");
// Two entries — one RedEther (type 1), one Card (type 5).
var entries = rewardList.EnumerateArray()
.Select(e => (Type: e.GetProperty("reward_type").GetInt32(),
Id: e.GetProperty("reward_id").GetInt64(),
Num: e.GetProperty("reward_num").GetInt32()))
.ToList();
Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 100)),
"RedEther post-state total = 2 * 50 = 100");
Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 3)),
"Card post-state owned count = 5 - 2 = 3");
}
[Test]
public async Task Destruct_without_auth_header_returns_401()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient(); // no auth header
var response = await client.PostAsync("/card/destruct",
DestructBody("{\"10001001\":\"1,1\"}"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[TestCase("", Description = "empty string")]
[TestCase("not json", Description = "non-JSON garbage")]
[TestCase("{\"10001001\":\"1\"}", Description = "value missing snapshot")]
[TestCase("{\"10001001\":\"0,5\"}", Description = "num=0 not allowed")]
[TestCase("{\"10001001\":\"-1,5\"}", Description = "negative num")]
[TestCase("{\"abc\":\"1,5\"}", Description = "non-numeric cardId")]
[TestCase("{\"10001001\":5}", Description = "value not a string")]
[TestCase("[]", Description = "root must be object, not array")]
public async Task Destruct_with_malformed_inner_json_returns_400(string innerJson)
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/card/destruct", DestructBody(innerJson));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
Assert.That(body, Does.Contain("malformed_request"));
}
[Test]
public async Task Destruct_with_empty_inner_object_returns_400()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/card/destruct", DestructBody("{}"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
Assert.That(body, Does.Contain("malformed_request"));
}
[Test]
public async Task Destruct_unknown_card_returns_400_unknown_card()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/card/destruct",
DestructBody("{\"99999999\":\"1,0\"}"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
Assert.That(body, Does.Contain("unknown_card"));
}
[Test]
public async Task Destruct_not_destructible_returns_400_not_destructible()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 0, craftCost: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/card/destruct",
DestructBody("{\"10001001\":\"1,3\"}"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
Assert.That(body, Does.Contain("not_destructible"));
}
[Test]
public async Task Destruct_protected_returns_400_card_protected()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50, isProtected: true);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/card/destruct",
DestructBody("{\"10001001\":\"1,3\"}"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
Assert.That(body, Does.Contain("card_protected"));
}
[Test]
public async Task Destruct_insufficient_cards_returns_400_insufficient_cards()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, dustReward: 50);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/card/destruct",
DestructBody("{\"10001001\":\"3,2\"}"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
Assert.That(body, Does.Contain("insufficient_cards"));
}
[Test]
public async Task Destruct_proceeds_when_client_possession_snapshot_disagrees_with_server()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// Server has 3 owned; client thinks it has 5 (stale snapshot).
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Inner JSON: destruct 1, client snapshot=5 (disagrees with server count=3).
// Spec: snapshot mismatch is warn-log only, never blocks the request.
var response = await client.PostAsync("/card/destruct",
DestructBody("{\"10001001\":\"1,5\"}"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
var entries = JsonDocument.Parse(body).RootElement
.GetProperty("reward_list")
.EnumerateArray()
.Select(e => (Type: e.GetProperty("reward_type").GetInt32(),
Id: e.GetProperty("reward_id").GetInt64(),
Num: e.GetProperty("reward_num").GetInt32()))
.ToList();
// Vials awarded based on actual server count, not client snapshot.
Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 50)));
Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 2)));
}
}

View File

@@ -216,6 +216,89 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
});
}
/// <summary>
/// Seeds an OwnedCardEntry for the viewer. Uses an existing card from the minimal test set
/// when <paramref name="cardId"/> matches one (10001001/10001002/10001003); otherwise the
/// caller must have inserted the card row themselves. <paramref name="dustReward"/> is written
/// onto the card's CollectionInfo so destruct tests can compute expected vials.
///
/// NOTE: This helper ALWAYS resets the viewer's RedEther to 0 (so destruct tests can assert
/// literal post-state totals). Callers that need a non-zero balance should re-assign after seeding.
/// </summary>
public async Task SeedOwnedCardAsync(
long viewerId,
long cardId,
int count,
int dustReward = 50,
int craftCost = 200,
bool isProtected = false)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId);
if (card is null)
{
card = new ShadowverseCardEntry
{
Id = cardId,
Name = $"SeededCard{cardId}",
Rarity = Rarity.Bronze,
CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward },
};
db.Cards.Add(card);
await db.SaveChangesAsync();
}
else if (card.CollectionInfo is null || card.CollectionInfo.DustReward != dustReward || card.CollectionInfo.CraftCost != craftCost)
{
card.CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward };
await db.SaveChangesAsync();
}
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 == cardId);
if (owned is null)
{
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = isProtected });
}
else
{
owned.Count = count;
owned.IsProtected = isProtected;
}
viewer.Currency.RedEther = 0; // Reset RedEther so destruct tests can assert literal post-state totals
await db.SaveChangesAsync();
}
/// <summary>
/// Puts <paramref name="count"/> copies of <paramref name="cardId"/> into the viewer's deck
/// in the given format + slot. Tests use this to set up deck-strip scenarios for /card/destruct.
/// The card must already exist (typically via SeedOwnedCardAsync, which inserts the card row).
/// </summary>
public async Task AddCardToDeckAsync(long viewerId, Format format, int deckNumber, long cardId, int count)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers
.Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var deck = viewer.Decks.First(d => d.Format == format && d.Number == deckNumber);
var card = await db.Cards.FirstAsync(c => c.Id == cardId);
var existing = deck.Cards.FirstOrDefault(c => c.Card.Id == cardId);
if (existing is null)
{
deck.Cards.Add(new DeckCard { Card = card, Count = count });
}
else
{
existing.Count = count;
}
await db.SaveChangesAsync();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);

View File

@@ -0,0 +1,284 @@
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;
/// <summary>
/// Coverage for <c>CardInventoryRepository.DestructCards</c>. 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 <c>CardControllerTests</c>.
/// </summary>
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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 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<SVSimDbContext>();
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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int>
{
{ 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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
Assert.That(outcome.IsSuccess, Is.True);
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 99_999_999L, 1 } });
Assert.That(outcome.IsSuccess, Is.False);
Assert.That(outcome.Error, Is.EqualTo(DestructError.UnknownCard));
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 3 } });
Assert.That(outcome.IsSuccess, Is.False);
Assert.That(outcome.Error, Is.EqualTo(DestructError.InsufficientCards));
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
Assert.That(outcome.IsSuccess, Is.False);
Assert.That(outcome.Error, Is.EqualTo(DestructError.CardProtected));
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
Assert.That(outcome.IsSuccess, Is.False);
Assert.That(outcome.Error, Is.EqualTo(DestructError.NotDestructible));
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ICardInventoryRepository>();
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int>
{
{ 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<SVSimDbContext>();
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<ICardInventoryRepository>();
// 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<long, int> { { 10001001L, 2 } });
Assert.That(outcome.IsSuccess, Is.True);
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ICardInventoryRepository>();
// Destruct 1 — owned drops from 3 to 2 — each deck must lose 1 copy.
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
Assert.That(outcome.IsSuccess, Is.True);
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ICardInventoryRepository>();
// Destruct 2 — owned drops from 5 to 3 — deck still uses only 2, no strip needed.
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 2 } });
Assert.That(outcome.IsSuccess, Is.True);
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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)");
}
}