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 PackControllerOpenTests { private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); /// /// Seeds an active pack (parent 10001, base = first seeded card set) with one rupee child /// gacha (gacha_id=400002, cost=100), then gives the viewer enough rupees to buy once. /// private static async Task SeedOpenablePack(SVSimTestFactory f, long viewerId, ulong rupees = 200) { int baseId; using (var scope = f.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); db.Packs.Add(new PackConfigEntry { Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "test", SleeveId = 3000011, ChildGachas = { new PackChildGachaEntry { GachaId = 400002, TypeDetail = 7, Cost = 100, CardCount = 8 }, }, }); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); v.Currency.Rupees = rupees; await db.SaveChangesAsync(); } return baseId; } [Test] public async Task Open_with_rupees_returns_8_cards_and_deducts_currency() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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 pack = doc.RootElement.GetProperty("pack_list"); Assert.That(pack.GetArrayLength(), Is.EqualTo(8)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v.Currency.Rupees, Is.EqualTo(100UL), "200 starting - 100 cost"); } [Test] public async Task Open_persists_drawn_cards_to_viewer_collection() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; await client.PostAsync("/pack/open", JsonBody(json)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.Include(x => x.Cards).ThenInclude(c => c.Card).FirstAsync(x => x.Id == viewerId); var totalGranted = v.Cards.Sum(c => c.Count); Assert.That(totalGranted, Is.EqualTo(8), "8 cards drawn, all persisted (Count sums to 8 even when duplicates collapse)."); } [Test] public async Task Open_increments_viewer_open_count() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; await client.PostAsync("/pack/open", JsonBody(json)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId); Assert.That(v.PackOpenCounts.Single(p => p.PackId == 10001).OpenCount, Is.EqualTo(1)); } [Test] public async Task Open_rejects_when_class_id_present() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"class_id":3}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); } [Test] public async Task Open_rejects_when_target_card_id_present() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"target_card_id":12345}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); } [Test] public async Task Open_rejects_skin_pack_category() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Packs.Add(new PackConfigEntry { Id = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "skin pack", ChildGachas = { new PackChildGachaEntry { GachaId = 700017, TypeDetail = 7, Cost = 100, CardCount = 8 } }, }); await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":70001,"gacha_id":700017,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); } [Test] public async Task Open_rejects_ticket_type_detail() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Packs.Add(new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "ticket-only pack", SleeveId = 5090001, ChildGachas = { new PackChildGachaEntry { GachaId = 920002, TypeDetail = 5, Cost = 1, CardCount = 8, ItemId = 92001 } }, }); await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":92001,"gacha_id":920002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented)); } [Test] public async Task Open_rejects_insufficient_rupees() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId, rupees: 50); // need 100 using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v.Currency.Rupees, Is.EqualTo(50UL), "no deduction on insufficient-funds reject"); } [Test] public async Task Open_with_crystals_deducts_crystals() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); db.Packs.Add(new PackConfigEntry { Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "test", ChildGachas = { new PackChildGachaEntry { GachaId = 100002, TypeDetail = 2, Cost = 100, CardCount = 8 } }, }); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); v.Currency.Crystals = 250; await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); // gacha_type:1 (parent pack's gacha_type) not :2 (child's type_detail) — see project_wire_pack_gacha_type memory. var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":100002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using (var scope2 = factory.Services.CreateScope()) { var db2 = scope2.ServiceProvider.GetRequiredService(); var v2 = await db2.Viewers.FirstAsync(x => x.Id == viewerId); Assert.That(v2.Currency.Crystals, Is.EqualTo(150UL)); } } [Test] public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); int baseId = await db.CardSets.Where(s => s.Cards.Count > 0).Select(s => s.Id).FirstAsync(); db.Packs.Add(new PackConfigEntry { Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None, CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30), GachaType = 1, GachaDetail = "daily test", ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = 3, Cost = 0, CardCount = 1, IsDailySingle = true } }, }); await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); // gacha_type:1 (parent pack's gacha_type) not :3 (child's type_detail=DAILY) — prod-correct. var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":200001,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var first = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync()); var second = await client.PostAsync("/pack/open", JsonBody(json)); Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "Second daily-free open the same UTC day should be rejected."); } // ---------------- Regression tests for wire-shape quirks ---------------- [Test] public async Task Open_succeeds_when_request_gacha_type_differs_from_child_type_detail() { // Prod client sends gacha_type=1 (parent pack's gacha_type) for every buy on a // gacha_type=1 pack, regardless of which child gacha is being bought (RUPY_MULTI=7, // DAILY=3, CRYSTAL_MULTI=2, TICKET_MULTI=5, ...). Server must NOT reject on // `child.TypeDetail != request.GachaType` — gacha_id alone identifies the child. // See project_wire_pack_gacha_type memory. using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); // gacha_id 400002 is RUPY_MULTI (type_detail=7); request sends gacha_type=1 — should succeed. var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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); } [Test] public async Task Open_reward_list_includes_post_state_rupee_balance() { // Client's PlayerStaticData.UpdateHaveUserGoodsNum does `UserRupyCount = reward_num` // (direct assignment). Without an entry, the on-screen rupee count stays stale until // restart / /mypage/refresh. Verify the entry shape: reward_type=9 (Rupy), reward_id=0, // reward_num=new post-state balance (starting 200 - cost 100 = 100). using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId, rupees: 200); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"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"); Assert.That(rewardList.GetArrayLength(), Is.GreaterThan(0), "reward_list must be populated — empty list leaves client cache stale."); var rupyEntry = Enumerable.Range(0, rewardList.GetArrayLength()) .Select(i => rewardList[i]) .FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 9); Assert.That(rupyEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), "missing Rupy (type=9) entry — client will keep showing the old rupee balance."); Assert.That(rupyEntry.GetProperty("reward_id").GetInt64(), Is.EqualTo(0L)); Assert.That(rupyEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(100), "reward_num is the new TOTAL balance (200 starting - 100 cost), not a delta."); } [Test] public async Task Open_reward_list_includes_post_state_card_counts() { // For each unique drawn card the client expects `{reward_type:5, reward_id:, // reward_num:}`. Without these the in-session collection cache // is stale (cards appear in the open animation but the collection view doesn't update). using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(); await SeedOpenablePack(factory, viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","parent_gacha_id":10001,"gacha_id":400002,"gacha_type":1,"pack_number":1,"exclude_card_ids":[]}"""; var response = await client.PostAsync("/pack/open", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var packList = doc.RootElement.GetProperty("pack_list"); var rewardList = doc.RootElement.GetProperty("reward_list"); // Every distinct card_id in pack_list must have a matching type=5 reward_list entry. var drawnCardIds = Enumerable.Range(0, packList.GetArrayLength()) .Select(i => packList[i].GetProperty("card_id").GetInt64()) .Distinct() .ToHashSet(); var cardEntryIds = Enumerable.Range(0, rewardList.GetArrayLength()) .Select(i => rewardList[i]) .Where(e => e.GetProperty("reward_type").GetInt32() == 5) .Select(e => e.GetProperty("reward_id").GetInt64()) .ToHashSet(); Assert.That(cardEntryIds, Is.SupersetOf(drawnCardIds), "Every unique drawn card_id must appear in reward_list with reward_type=5."); } }