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 PackControllerTests { [Test] public async Task PackInfo_item_number_reflects_owned_ticket_count() { // Verifies the ownedItemsByItemId projection in PackController.Info — the dict that // drives child_gacha_info.item_number. Tutorial flow filters packs by item_number > 0, // so a regression on the projection (e.g. nav-eval collapsing to 0) silently hides // any pack that requires a ticket. using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 41); // Seed item 90001 with count 7 — the legendary starter ticket the tutorial gift grants. await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 7, itemName: "Starter Legendary Ticket"); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/pack/info", new StringContent(json, Encoding.UTF8, "application/json")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); // Find pack 99047 (the starter legendary) and verify its child gacha reports item_number=7. var pack99047 = doc.RootElement.GetProperty("pack_config_list").EnumerateArray() .First(p => p.GetProperty("parent_gacha_id").GetInt32() == 99047); var childWithTicket = pack99047.GetProperty("child_gacha_info").EnumerateArray() .First(c => c.TryGetProperty("item_id", out var iid) && iid.GetString() == "90001"); Assert.That(childWithTicket.GetProperty("item_number").GetInt32(), Is.EqualTo(7), "child_gacha_info.item_number must reflect the viewer's owned count of the gating " + "item; client filters tutorial packs on item_number > 0."); } [Test] public async Task TutorialPackInfo_returns_same_list_as_pack_info() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 41); using var client = factory.CreateAuthenticatedClient(viewerId); var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var direct = await client.PostAsync("/pack/info", new StringContent(json, Encoding.UTF8, "application/json")); var tutorial = await client.PostAsync("/tutorial/pack_info", new StringContent(json, Encoding.UTF8, "application/json")); Assert.That(direct.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(tutorial.StatusCode, Is.EqualTo(HttpStatusCode.OK)); var directBody = await direct.Content.ReadAsStringAsync(); var tutorialBody = await tutorial.Content.ReadAsStringAsync(); Assert.That(tutorialBody, Is.EqualTo(directBody), "tutorial/pack_info wire shape must match /pack/info exactly (no filtering in v1)."); } [Test] public async Task TutorialPackOpen_grants_pack_and_sets_tutorial_step_100() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 41); // Seed the starter ticket the gift_receive step would have granted. /tutorial/pack_open // is supposed to decrement this count by `pack_number` (1) and emit a post-state entry // into reward_list (per project_wire_reward_list_post_state). await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket"); // Pack 99047 (starter legendary) has base_pack_id=90001. The minimal card seed only // creates set 10001, so we seed set 90001 explicitly for the pool resolver. using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.CardSets.Add(new ShadowverseCardSetEntry { Id = 90001, Name = "TutorialStarterSet", IsInRotation = true, IsBasic = false, Cards = [ new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze }, new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold }, new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary }, ], }); await db.SaveChangesAsync(); } using var client = factory.CreateAuthenticatedClient(viewerId); var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/tutorial/pack_open", new StringContent(requestJson, Encoding.UTF8, "application/json")); 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.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100), "tutorial/pack_open must include tutorial_step=100 in data — this is the END transition."); Assert.That(root.GetProperty("pack_list").GetArrayLength(), Is.EqualTo(8), "Starter pack 99047/990047 delivers 8 cards (child_gacha.card_count=8)."); Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100)); // Ticket decrement: the legendary starter ticket (90001) should be consumed. Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0), "Tutorial pack_open must decrement the gating ticket; otherwise /tutorial/pack_info " + "keeps showing the pack and the client re-clicks into /pack/open (501 on type_detail=5)."); // reward_list must carry a post-state item entry for the ticket. RewardType=4 (Item), // RewardId=90001, RewardNum=0 (post-state total, NOT delta). var rewardList = root.GetProperty("reward_list"); var ticketEntry = rewardList.EnumerateArray() .FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 4 && e.GetProperty("reward_id").GetInt64() == 90001); Assert.That(ticketEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), "reward_list must include a type=4 entry for the consumed ticket (90001) so the " + "client's _userItemDict updates immediately — project_wire_reward_list_post_state."); Assert.That(ticketEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(0), "RewardNum is the post-state TOTAL, not the delta consumed."); } [Test] public async Task NonTutorial_pack_open_does_not_emit_tutorial_step() { // Verify that regular /pack/open still works AND does not include tutorial_step in the response. // Use the tutorial pack (99047/990047) which has type_detail=5 — the non-tutorial path // still hits the currency_path_not_implemented guard and returns 501. using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 100); using var client = factory.CreateAuthenticatedClient(viewerId); var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/pack/open", new StringContent(requestJson, Encoding.UTF8, "application/json")); // Non-tutorial pack/open + type_detail=5 STILL returns 501 — that's the established behavior. Assert.That((int)response.StatusCode, Is.EqualTo(501), "Non-tutorial /pack/open with type_detail=5 should still hit the currency_path_not_implemented guard."); // Even on a 501, no tutorial_step field should appear in the response body. var body = await response.Content.ReadAsStringAsync(); Assert.That(body.Contains("\"tutorial_step\""), Is.False, "Regular /pack/open must never emit tutorial_step."); } [Test] public async Task TutorialPackOpen_rejects_non_starter_parent_gacha_id() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 41); using var client = factory.CreateAuthenticatedClient(viewerId); // Pick any non-99047 parent_gacha_id seeded by SeedGlobalsAsync (10032 is the most // recent crystal-multi pack in the catalog). The alias must reject it BadRequest. var requestJson = """{"parent_gacha_id":10032,"gacha_id":100320,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/tutorial/pack_open", new StringContent(requestJson, Encoding.UTF8, "application/json")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "Tutorial alias must only accept the starter pack (99047); otherwise any authenticated " + "viewer can draw any pack for free via the currency-bypass tutorial path."); // State must NOT have advanced. Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41), "Rejected requests leave TutorialState untouched."); } [Test] public async Task TutorialPackOpen_rejects_completed_viewer() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 100); await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket"); using var client = factory.CreateAuthenticatedClient(viewerId); var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/tutorial/pack_open", new StringContent(requestJson, Encoding.UTF8, "application/json")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), "Tutorial alias must reject viewers past the tutorial-end gate (state>=100); the path " + "would otherwise re-clobber state and consume a ticket the viewer kept post-tutorial."); Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(1), "Rejected requests do not consume tickets."); } [Test] public async Task TutorialPackOpen_does_not_downgrade_state_past_100() { // This is the max-preserve check. A future state > 100 (e.g., a post-tutorial training // sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100, // so synthesize the case directly. using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 200); using var client = factory.CreateAuthenticatedClient(viewerId); var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; var response = await client.PostAsync("/tutorial/pack_open", new StringContent(requestJson, Encoding.UTF8, "application/json")); // Either the request is rejected (because state>=100, see Gate B above), OR — if the // implementation reads the gate differently — at minimum the persisted state must not // regress. Encode the load-bearing invariant: state never goes backwards. Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200), "TutorialState must not regress regardless of the alias's accept/reject decision."); } }