Files
SVSimServer/SVSim.UnitTests/Controllers/BuildDeckControllerBuyTests.cs
gamer147 05d8169012 refactor: type reward_type columns as UserGoodsType enum
Replace bare `int RewardType` on 12 catalog/reward entities and GrantedReward
with the existing UserGoodsType enum. Verified against the decompiled client:
every wire reward_type decodes through the single Wizard.UserGoods.Type enum, so
one enum is correct across all endpoint families (item_type is a separate
Item.Type axis, left untouched). EF stores the enum as the same int column, so
there is no migration.

- Importers cast seed int -> UserGoodsType at the ingest boundary.
- New GrantedReward.ToRewardList() extension replaces 8 copy-pasted
  GrantedReward -> RewardListEntry projections.
- Fix 3 .ToString() sites that would otherwise emit enum names ("Crystal")
  instead of the int wire value ("2").
- Wire DTOs keep int; the enum is widened to int at the wire boundary only.

Build green; 962/962 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:50:49 -04:00

442 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
public class BuildDeckControllerBuyTests
{
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
/// <summary>
/// Seeds: series 101 (enabled), one crystal-priced product 1 (intro=500/regular=750, max=3)
/// containing 2 distinct cards (10001001 ×2, 10001002 ×1). Caller may set viewer crystals.
/// </summary>
private static async Task SeedCrystalProduct(SVSimTestFactory f, long viewerId, ulong crystals)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
{
Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "BDSSN_test", IntroKey = "BDSI_test",
Products =
{
new BuildDeckProductEntry
{
Id = 1, SeriesId = 101, LeaderId = 1, DeckCode = "pd0101",
PurchaseNumMax = 3, IntroPriceCrystal = 500, RegularPriceCrystal = 750,
IsEnabled = true,
Cards =
{
new BuildDeckProductCardEntry { CardId = 10001001L, Number = 2, IsSpot = false },
new BuildDeckProductCardEntry { CardId = 10001002L, Number = 1, IsSpot = false },
},
},
},
});
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Crystals = crystals;
await db.SaveChangesAsync();
}
private static async Task SeedRupyProduct(SVSimTestFactory f, long viewerId, ulong rupees)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
{
Id = 102, OrderIndex = 23, IsEnabled = true, NameKey = "BDSSN_rupy", IntroKey = "BDSI_rupy",
Products =
{
new BuildDeckProductEntry
{
Id = 10, SeriesId = 102, LeaderId = 2, DeckCode = "pdR",
PurchaseNumMax = 1, IntroPriceRupy = 100,
IsEnabled = true,
Cards = { new BuildDeckProductCardEntry { CardId = 10001001L, Number = 1, IsSpot = false } },
},
},
});
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Rupees = rupees;
await db.SaveChangesAsync();
}
private static async Task SeedFreeProduct(SVSimTestFactory f, long viewerId)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
{
Id = 103, OrderIndex = 24, IsEnabled = true, NameKey = "BDSSN_free", IntroKey = "BDSI_free",
Products =
{
new BuildDeckProductEntry
{
Id = 20, SeriesId = 103, LeaderId = 3, DeckCode = "pdF",
PurchaseNumMax = 1, IntroPriceCrystal = 0, IntroPriceRupy = 0,
IsEnabled = true,
Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } },
},
},
});
await db.SaveChangesAsync();
}
/// <summary>
/// Seeds: series 104 + product 100 with a per-buy sleeve reward (id 3000021, a real seeded
/// sleeve master row). Used to verify the per-buy rewards path that drops sleeve/emblem/skin
/// grants if the controller's Rewards iteration is missing.
/// </summary>
private static async Task SeedProductWithSleeveReward(SVSimTestFactory f, long viewerId)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
{
Id = 104, OrderIndex = 19, IsEnabled = true, NameKey = "BDSSN_sleeve", IntroKey = "BDSI_sleeve",
Products =
{
new BuildDeckProductEntry
{
Id = 100, SeriesId = 104, LeaderId = 1, DeckCode = "pd0104",
PurchaseNumMax = 1, IntroPriceCrystal = 0, IntroPriceRupy = 0, // free
IsEnabled = true,
Cards = { new BuildDeckProductCardEntry { CardId = 10001001L, Number = 1, IsSpot = false } },
Rewards =
{
new BuildDeckProductRewardEntry
{
RewardIndex = 1, RewardType = (UserGoodsType)6 /* Sleeve */,
RewardDetailId = 3000021, RewardNumber = 1, MessageId = 51004,
},
},
},
},
});
await db.SaveChangesAsync();
}
[Test]
public async Task Buy_grants_per_buy_sleeve_reward_to_viewer_collection()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedProductWithSleeveReward(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":100,"sales_type":0}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId);
Assert.That(v.Sleeves.Any(s => s.Id == 3000021), Is.True,
"per-buy sleeve reward must land in viewer's owned collection");
using var doc = JsonDocument.Parse(body);
var entries = doc.RootElement.GetProperty("reward_list");
bool foundSleeve = false;
for (int i = 0; i < entries.GetArrayLength(); i++)
{
var e = entries[i];
if (e.GetProperty("reward_type").GetInt32() == 6 && e.GetProperty("reward_id").GetInt64() == 3000021)
foundSleeve = true;
}
Assert.That(foundSleeve, Is.True, "reward_list must include the granted sleeve entry");
}
[Test]
public async Task Crystal_buy_debits_intro_price_and_grants_cards()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers
.Include(x => x.Cards).ThenInclude(c => c.Card)
.Include(x => x.BuildDeckPurchases)
.FirstAsync(x => x.Id == viewerId);
Assert.That(v.Currency.Crystals, Is.EqualTo(500UL), "1000 - 500 intro");
Assert.That(v.Cards.Sum(c => c.Count), Is.EqualTo(3), "2 + 1 cards granted");
Assert.That(v.BuildDeckPurchases.Single(p => p.ProductId == 1).PurchaseCount, Is.EqualTo(1));
}
[Test]
public async Task Crystal_buy_emits_post_state_total_for_crystals()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var rewardList = doc.RootElement.GetProperty("reward_list");
bool foundCrystals = false;
for (int i = 0; i < rewardList.GetArrayLength(); i++)
{
var e = rewardList[i];
if (e.GetProperty("reward_type").GetInt32() == 2)
{
Assert.That(e.GetProperty("reward_num").GetInt32(), Is.EqualTo(500), "post-state crystals total");
foundCrystals = true;
}
}
Assert.That(foundCrystals, Is.True, "crystal entry must be in reward_list");
}
[Test]
public async Task Returns_BadRequest_when_insufficient_crystals()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCrystalProduct(factory, viewerId, crystals: 100);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Returns_BadRequest_for_disabled_product()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
{
Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "x", IntroKey = "x",
Products =
{
new BuildDeckProductEntry
{
Id = 999, SeriesId = 101, PurchaseNumMax = 1, IntroPriceCrystal = 500,
IsEnabled = false,
},
},
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":999,"sales_type":1}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Returns_BadRequest_when_purchase_limit_reached()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCrystalProduct(factory, viewerId, crystals: 10000);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.BuildDeckPurchases).FirstAsync(x => x.Id == viewerId);
v.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = 1, PurchaseCount = 3 });
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Returns_BadRequest_when_paying_in_unsupported_currency_for_product()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCrystalProduct(factory, viewerId, crystals: 1000); // crystal-only product
using var client = factory.CreateAuthenticatedClient(viewerId);
// sales_type=2 (rupy) against a crystal-only product
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":2}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Returns_501_for_ticket_sales_type()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":3}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
Assert.That((int)response.StatusCode, Is.EqualTo(501));
}
[Test]
public async Task Rupy_buy_debits_and_grants()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedRupyProduct(factory, viewerId, rupees: 200);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":10,"sales_type":2}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(x => x.Cards).FirstAsync(x => x.Id == viewerId);
Assert.That(v.Currency.Rupees, Is.EqualTo(100UL));
using var doc = JsonDocument.Parse(body);
var entries = doc.RootElement.GetProperty("reward_list");
bool foundRupy = false;
for (int i = 0; i < entries.GetArrayLength(); i++)
{
if (entries[i].GetProperty("reward_type").GetInt32() == 9)
{
Assert.That(entries[i].GetProperty("reward_num").GetInt32(), Is.EqualTo(100));
foundRupy = true;
}
}
Assert.That(foundRupy, Is.True);
}
[Test]
public async Task Free_buy_grants_cards_without_currency_entry()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedFreeProduct(factory, viewerId);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":20,"sales_type":0}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var entries = doc.RootElement.GetProperty("reward_list");
for (int i = 0; i < entries.GetArrayLength(); i++)
{
int t = entries[i].GetProperty("reward_type").GetInt32();
Assert.That(t, Is.Not.EqualTo(2), "free buy must not emit Crystal entry");
Assert.That(t, Is.Not.EqualTo(9), "free buy must not emit Rupy entry");
}
}
[Test]
public async Task Free_buy_against_nonfree_product_returns_BadRequest()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedCrystalProduct(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":0}""";
var response = await client.PostAsync("/build_deck/buy", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Buy_emits_newly_unlocked_series_tier_rewards()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.BuildDeckSeries.Add(new BuildDeckSeriesEntry
{
Id = 105, OrderIndex = 18, IsEnabled = true, NameKey = "x", IntroKey = "x",
SeriesRewards =
{
// Tier 1: one card reward, unlocked on the 1st series purchase.
new BuildDeckSeriesRewardEntry
{
TierIndex = 1, ItemIndex = 0, RewardType = (UserGoodsType)5,
RewardDetailId = 10001001L, RewardNumber = 1, MessageId = 51004,
},
// Tier 2: one card reward, unlocked on the 2nd series purchase.
new BuildDeckSeriesRewardEntry
{
TierIndex = 2, ItemIndex = 0, RewardType = (UserGoodsType)5,
RewardDetailId = 10001002L, RewardNumber = 1, MessageId = 51004,
},
},
Products =
{
new BuildDeckProductEntry
{
Id = 501, SeriesId = 105, LeaderId = 1, DeckCode = "pd0501",
PurchaseNumMax = 3, IntroPriceCrystal = 0, RegularPriceCrystal = 0,
IntroPriceRupy = 0, RegularPriceRupy = 0, IsEnabled = true,
Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } },
},
new BuildDeckProductEntry
{
Id = 502, SeriesId = 105, LeaderId = 2, DeckCode = "pd0502",
PurchaseNumMax = 3, IntroPriceCrystal = 0, RegularPriceCrystal = 0,
IntroPriceRupy = 0, RegularPriceRupy = 0, IsEnabled = true,
Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } },
},
},
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
// 1st series purchase (product 501) should emit tier 1 only.
var r1 = await client.PostAsync("/build_deck/buy",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":501,"sales_type":0}"""));
Assert.That(r1.StatusCode, Is.EqualTo(HttpStatusCode.OK), await r1.Content.ReadAsStringAsync());
using (var doc = JsonDocument.Parse(await r1.Content.ReadAsStringAsync()))
{
var tiers = doc.RootElement.GetProperty("series_rewards");
Assert.That(tiers.GetArrayLength(), Is.EqualTo(1), "only tier 1 newly crossed");
Assert.That(tiers[0].GetProperty("reward_detail_id").GetInt64(), Is.EqualTo(10001001L));
}
// 2nd series purchase (product 502) should emit tier 2 only.
var r2 = await client.PostAsync("/build_deck/buy",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":502,"sales_type":0}"""));
using (var doc = JsonDocument.Parse(await r2.Content.ReadAsStringAsync()))
{
var tiers = doc.RootElement.GetProperty("series_rewards");
Assert.That(tiers.GetArrayLength(), Is.EqualTo(1));
Assert.That(tiers[0].GetProperty("reward_detail_id").GetInt64(), Is.EqualTo(10001002L));
}
}
}