Files
SVSimServer/SVSim.UnitTests/Controllers/LeaderSkinShopControllerTests.cs
gamer147 a5999a3e9c feat(leader-skin): shop catalog + 5 endpoints (/products, /buy, /buy_set, /buy_set_item, /ids)
Schema: LeaderSkinShopSeries -> Products (owned rewards) + owned
SetCompletionRewards on the series; ViewerLeaderSkinSetClaim composite
PK (ViewerId, SeriesId) backs the /buy_set_item idempotent-claim check.

Importer mirrors SleeveShopImporter: idempotent find-or-create, owned
collections rewritten wholesale on rerun. 16 series, 104 products.

Controller (extends existing /set with 5 new endpoints):
- /products: dict-keyed-by-series_id-string wire shape. is_completed
  per-viewer, rewards.status from ViewerLeaderSkinSetClaim (0=no set
  sale, 1=available, 2=claimed) matching client RewardStatus enum.
- /buy: single skin, sales_type 1/2 dispatch, 3=>501.
- /buy_set: whole series at SetPrice; requires set_sales_status != 0;
  grants every product's rewards (RewardGrantService idempotent on
  already-owned cosmetics, so partial-set buys don't double-add).
- /buy_set_item: requires viewer owns every skin in series; idempotent
  on re-claim (returns 200 + empty reward_list, not 400) so client
  retries don't error.
- /ids: flat owned-skin-id list for badge refresh.

496 tests pass (was 486; +10 leader-skin-shop tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:55:09 -04:00

298 lines
14 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Tests for the four new shop endpoints (/products, /buy, /buy_set, /buy_set_item, /ids).
/// Existing /set tests live in the older smoke-test files; this class only covers the new
/// surface added with the leader-skin-shop family.
/// </summary>
public class LeaderSkinShopControllerTests
{
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
/// <summary>
/// Seeds one series (9001) with 2 products (skins 9101, 9102). Each product grants
/// only its skin (no emblem/sleeve cascade — keeps the test self-contained without
/// touching the cosmetic catalog). Set sale active at 800 crystals / 800 rupy.
/// Set-completion bonus is 500 rupy.
/// </summary>
private static async Task SeedShop(SVSimTestFactory f)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// LeaderSkin cosmetic catalog rows (RewardGrantService.AddCosmeticIfMissing looks these up)
if (!await db.LeaderSkins.AnyAsync(s => s.Id == 9101))
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 9101, ClassId = 1 });
if (!await db.LeaderSkins.AnyAsync(s => s.Id == 9102))
db.LeaderSkins.Add(new LeaderSkinEntry { Id = 9102, ClassId = 1 });
db.LeaderSkinShopSeries.Add(new LeaderSkinShopSeriesEntry
{
Id = 9001, IsEnabled = true, IsNew = false,
SetSalesStatus = 1, SetPriceCrystal = 800, SetPriceRupy = 800,
Products =
{
new LeaderSkinShopProductEntry
{
Id = 90011, SeriesId = 9001, LeaderSkinId = 9101,
ProductNameKey = "LSPPN_test_1", IntroductionKey = "LSPI_test_1", CvNameKey = "LSPCN_test_1",
SinglePriceCrystal = 500, SinglePriceRupy = 500, IsEnabled = true,
Rewards = { new LeaderSkinShopProductRewardEntry { OrderIndex = 0, RewardType = 10, RewardDetailId = 9101, RewardNumber = 1 } },
},
new LeaderSkinShopProductEntry
{
Id = 90012, SeriesId = 9001, LeaderSkinId = 9102,
ProductNameKey = "LSPPN_test_2", IntroductionKey = "LSPI_test_2", CvNameKey = "LSPCN_test_2",
SinglePriceCrystal = 500, SinglePriceRupy = 500, IsEnabled = true,
Rewards = { new LeaderSkinShopProductRewardEntry { OrderIndex = 0, RewardType = 10, RewardDetailId = 9102, RewardNumber = 1 } },
},
},
SetCompletionRewards =
{
new LeaderSkinShopSeriesRewardEntry { OrderIndex = 0, RewardType = 9, RewardDetailId = 0, RewardNumber = 500 },
},
});
await db.SaveChangesAsync();
}
private static async Task SetViewerCurrency(SVSimTestFactory f, long viewerId, ulong crystals = 0, ulong rupies = 0)
{
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.Currency.Crystals = crystals;
v.Currency.Rupees = rupies;
await db.SaveChangesAsync();
}
[Test]
public async Task Products_returns_dict_keyed_by_series_id_with_set_fields_emitted()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedShop(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/leader_skin/products",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Object), "wire shape is dict-keyed by series_id string");
var series = root.GetProperty("9001");
Assert.That(series.GetProperty("series_id").GetInt32(), Is.EqualTo(9001));
Assert.That(series.GetProperty("set_sales_status").GetInt32(), Is.EqualTo(1));
Assert.That(series.GetProperty("set_prices").GetProperty("set_price_crystal").GetInt32(), Is.EqualTo(800));
var products = series.GetProperty("products");
Assert.That(products.GetArrayLength(), Is.EqualTo(2));
Assert.That(products[0].GetProperty("is_purchased").GetBoolean(), Is.False);
// set_completion bonus item should be in rewards.items
var rewards = series.GetProperty("rewards");
Assert.That(rewards.GetProperty("items").GetArrayLength(), Is.EqualTo(1));
}
[Test]
public async Task Buy_single_crystal_debits_and_grants_skin()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedShop(factory);
await SetViewerCurrency(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/leader_skin/buy",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
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");
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(2)); // crystal post-state + skin grant
var crystal = rewardList[0];
Assert.That(crystal.GetProperty("reward_type").GetInt32(), Is.EqualTo(2));
Assert.That(crystal.GetProperty("reward_num").GetInt32(), Is.EqualTo(500));
var skin = rewardList[1];
Assert.That(skin.GetProperty("reward_type").GetInt32(), Is.EqualTo(10));
Assert.That(skin.GetProperty("reward_id").GetInt64(), Is.EqualTo(9101));
// Viewer should now own skin 9101
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
Assert.That(v.LeaderSkins.Any(s => s.Id == 9101), Is.True);
}
[Test]
public async Task Buy_already_purchased_skin_rejects_with_400()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedShop(factory);
await SetViewerCurrency(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var first = await client.PostAsync("/leader_skin/buy",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var second = await client.PostAsync("/leader_skin/buy",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Buy_ticket_sales_type_returns_501()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedShop(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/leader_skin/buy",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":3,"item_id":900001}"""));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));
}
[Test]
public async Task BuySet_grants_all_skins_in_series()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedShop(factory);
await SetViewerCurrency(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/leader_skin/buy_set",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001,"sales_type":1,"item_id":null}"""));
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");
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(3)); // crystal post + skin1 + skin2
// Viewer should own both skins
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
Assert.That(v.LeaderSkins.Count(s => s.Id == 9101 || s.Id == 9102), Is.EqualTo(2));
Assert.That(v.Currency.Crystals, Is.EqualTo(200UL)); // 1000 - 800 set price
}
[Test]
public async Task BuySetItem_rejects_until_series_completed_then_succeeds()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedShop(factory);
await SetViewerCurrency(factory, viewerId, rupies: 5000);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Without owning any skin: rejected
var early = await client.PostAsync("/leader_skin/buy_set_item",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001}"""));
Assert.That(early.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
// Buy both skins via buy_set
var setBuy = await client.PostAsync("/leader_skin/buy_set",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001,"sales_type":2,"item_id":null}"""));
Assert.That(setBuy.StatusCode, Is.EqualTo(HttpStatusCode.OK));
// Now claim succeeds, grants the bonus (500 rupy)
var claim = await client.PostAsync("/leader_skin/buy_set_item",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001}"""));
var body = await claim.Content.ReadAsStringAsync();
Assert.That(claim.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var rewardList = doc.RootElement.GetProperty("reward_list");
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(1));
Assert.That(rewardList[0].GetProperty("reward_type").GetInt32(), Is.EqualTo(9)); // Rupy
// Second claim returns OK with empty reward_list (idempotent — not 400)
var second = await client.PostAsync("/leader_skin/buy_set_item",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9001}"""));
var secondBody = await second.Content.ReadAsStringAsync();
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc2 = JsonDocument.Parse(secondBody);
Assert.That(doc2.RootElement.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0));
}
[Test]
public async Task Ids_returns_owned_leader_skin_ids()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedShop(factory);
await SetViewerCurrency(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Initial state: no owned skins from our shop
var beforeResp = await client.PostAsync("/leader_skin/ids",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
var beforeBody = await beforeResp.Content.ReadAsStringAsync();
using var beforeDoc = JsonDocument.Parse(beforeBody);
bool ownsBefore = false;
foreach (var id in beforeDoc.RootElement.GetProperty("user_leader_skin_ids").EnumerateArray())
if (id.GetInt32() == 9101) { ownsBefore = true; break; }
Assert.That(ownsBefore, Is.False);
// Buy skin 9101
await client.PostAsync("/leader_skin/buy",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":90011,"sales_type":1,"item_id":null}"""));
var afterResp = await client.PostAsync("/leader_skin/ids",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
var afterBody = await afterResp.Content.ReadAsStringAsync();
using var afterDoc = JsonDocument.Parse(afterBody);
bool ownsAfter = false;
foreach (var id in afterDoc.RootElement.GetProperty("user_leader_skin_ids").EnumerateArray())
if (id.GetInt32() == 9101) { ownsAfter = true; break; }
Assert.That(ownsAfter, Is.True);
}
[Test]
public async Task BuySet_on_series_without_set_sale_rejects()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.LeaderSkinShopSeries.Add(new LeaderSkinShopSeriesEntry
{
Id = 9999, IsEnabled = true, SetSalesStatus = 0, // no set sale
Products = { new LeaderSkinShopProductEntry { Id = 99991, SeriesId = 9999, LeaderSkinId = 1, IsEnabled = true, SinglePriceCrystal = 500 } },
});
await db.SaveChangesAsync();
}
await SetViewerCurrency(factory, viewerId, crystals: 1000);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/leader_skin/buy_set",
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","series_id":9999,"sales_type":1,"item_id":null}"""));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
}