using System.Net; using System.Text; using System.Text.Json; using NUnit.Framework; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; public class GiftControllerTests { private const string BaseAuthBlock = @"""viewer_id"":""0"",""steam_id"":0,""steam_session_ticket"":"""""; [Test] public async Task GiftTop_returns_five_tutorial_gifts_for_unclaimed_viewer() { using var factory = new SVSimTestFactory(); long viewerId = await factory.SeedViewerAsync(tutorialState: 31); await factory.SeedTutorialPresentsAsync(viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var response = await client.PostAsync("/tutorial/gift_top", new StringContent($$"""{"page":1,{{BaseAuthBlock}}}""", Encoding.UTF8, "application/json")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var root = doc.RootElement; var presents = root.GetProperty("present_list"); Assert.That(presents.GetArrayLength(), Is.EqualTo(5)); // Expect the legendary pack entry (present_id 71478630) to be present. bool foundLegendaryGift = false; foreach (var p in presents.EnumerateArray()) { if (p.GetProperty("present_id").GetString() == "71478630") { foundLegendaryGift = true; Assert.That(p.GetProperty("reward_type").GetString(), Is.EqualTo("4")); Assert.That(p.GetProperty("reward_detail_id").GetString(), Is.EqualTo("90001")); Assert.That(p.GetProperty("reward_count").GetString(), Is.EqualTo("1")); Assert.That(p.GetProperty("item_type").GetInt32(), Is.EqualTo(2)); Assert.That(p.GetProperty("message").GetString(), Is.EqualTo("For completing the tutorial")); } } Assert.That(foundLegendaryGift, Is.True, "Legendary starter pack gift (71478630) must be in present_list."); Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(0)); Assert.That(root.GetProperty("limit_over_present_list").GetArrayLength(), Is.EqualTo(0)); } [Test] public async Task GiftReceive_grants_currency_and_items_then_history_is_populated() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 31); await factory.SeedTutorialPresentsAsync(viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var pre = await factory.GetViewerCurrencyAsync(viewerId); var requestJson = $$""" {"present_id_array":["71478626","71478627","71478628","71478629","71478630"],"state":1,{{BaseAuthBlock}}} """; var response = await client.PostAsync("/tutorial/gift_receive", new StringContent(requestJson, Encoding.UTF8, "application/json")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var root = doc.RootElement; // Five received ids echoed. var ids = root.GetProperty("received_ids").EnumerateArray() .Select(e => e.GetString()).ToHashSet(); Assert.That(ids, Is.EquivalentTo(new[] { "71478626", "71478627", "71478628", "71478629", "71478630" })); // present_list emptied, history populated. Assert.That(root.GetProperty("present_list").GetArrayLength(), Is.EqualTo(0)); Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(5)); // Currency credited: +400 crystals, +100 rupees. var post = await factory.GetViewerCurrencyAsync(viewerId); Assert.That(post.Crystals - pre.Crystals, Is.EqualTo(400UL)); Assert.That(post.Rupees - pre.Rupees, Is.EqualTo(100UL)); // reward_list carries post-state TOTALS, not deltas, per project_wire_reward_list_post_state. // After claiming gifts, the crystal/rupy entries in reward_list should equal viewer's post-grant totals. var rewardList = root.GetProperty("reward_list").EnumerateArray().ToList(); var crystalEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "1"); var rupyEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "9"); Assert.That(crystalEntry.GetProperty("reward_num").GetString(), Is.EqualTo(post.Crystals.ToString()), "reward_list currency entries must carry POST-STATE TOTALS, not gift deltas (client does direct assignment)."); Assert.That(rupyEntry.GetProperty("reward_num").GetString(), Is.EqualTo(post.Rupees.ToString())); } [Test] public async Task GiftReceive_advances_tutorial_state_from_31_to_41() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 31); await factory.SeedTutorialPresentsAsync(viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = $$"""{"present_id_array":["71478626"],"state":1,{{BaseAuthBlock}}}"""; var response = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var root = doc.RootElement; // Response carries the new step inline. Assert.That(root.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(41)); // Only 1 of 5 gifts claimed → 4 remain unclaimed → badge state must be "still has presents". Assert.That(root.GetProperty("is_unreceived_present").GetBoolean(), Is.True, "Partial claim leaves 4 gifts unclaimed in present_list — is_unreceived_present " + "must reflect that so the client's inbox badge keeps surfacing."); Assert.That(root.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(1)); Assert.That(root.GetProperty("present_list").GetArrayLength(), Is.EqualTo(4)); // Side effect: viewer state advanced to 41. Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41)); } [Test] public async Task GiftReceive_returns_empty_received_ids_on_idempotent_replay() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 31); await factory.SeedTutorialPresentsAsync(viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = $$"""{"present_id_array":["71478626","71478627"],"state":1,{{BaseAuthBlock}}}"""; // First call grants both gifts. await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); // Second call (replay) must return empty received_ids / total_receive_count_list / // reward_list — these lists describe what THIS call granted, not what the client // asked for. Echoing requested ids would re-fire the client's "received N gifts" // popup and direct-assign the same post-state totals again. var second = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var doc = JsonDocument.Parse(await second.Content.ReadAsStringAsync()); var root = doc.RootElement; Assert.That(root.GetProperty("received_ids").GetArrayLength(), Is.EqualTo(0), "Idempotent re-claim grants nothing → received_ids empty."); Assert.That(root.GetProperty("total_receive_count_list").GetArrayLength(), Is.EqualTo(0)); Assert.That(root.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0)); // present_history_list still includes the originally-claimed gifts. Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(2)); } [Test] public async Task GiftReceive_echoes_persisted_tutorial_step_not_hardcoded_41() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); // Viewer is past the tutorial entirely (state=100). The gift_receive endpoint is // still reachable via /tutorial/gift_receive — a stale client retry, for instance. // The persistence side max-preserves (keeps state at 100); the response must echo // 100, not the hardcoded 41 the endpoint used to emit, or the client's tutorial // state machine regresses on a no-op retry. long viewerId = await factory.SeedViewerAsync(tutorialState: 100); await factory.SeedTutorialPresentsAsync(viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = $$"""{"present_id_array":["71478626"],"state":1,{{BaseAuthBlock}}}"""; var response = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100)); Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100)); } [Test] public async Task GiftReceive_with_pre_owned_item_increments_existing_row() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 31); // Seed item 1 (= the 3-item gift's reward_detail_id) with count=5 pre-existing. // Any non-tutorial source could leave a viewer here — battlepass, future reward, // admin import. Gift 71478628 grants +3 of item 1; the existing row must be // found and incremented, not duplicated. The (ViewerId, ItemId) unique index // added 2026-05-25 would otherwise throw on SaveChanges → 500 to the client. await factory.SeedOwnedItemAsync(viewerId, itemId: 1, count: 5, itemName: "PreOwnedItem"); await factory.SeedTutorialPresentsAsync(viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var json = $$"""{"present_id_array":["71478628"],"state":1,{{BaseAuthBlock}}}"""; var response = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); var bodyStr = await response.Content.ReadAsStringAsync(); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), bodyStr); // Existing row was incremented to 8 (5 + 3), not duplicated. Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 1), Is.EqualTo(8), "Pre-existing OwnedItemEntry must be found via the ThenIncluded Item nav; " + "otherwise RewardGrantService falls through to add a new row and the " + "(ViewerId, ItemId) unique index throws on SaveChanges."); // reward_list reflects the post-state total (8), not the gift delta (3). using var doc = JsonDocument.Parse(bodyStr); var itemEntry = doc.RootElement.GetProperty("reward_list").EnumerateArray() .First(e => e.GetProperty("reward_type").GetString() == "4" && e.GetProperty("reward_id").GetString() == "1"); Assert.That(itemEntry.GetProperty("reward_num").GetString(), Is.EqualTo("8"), "RewardNum carries the POST-STATE TOTAL — client direct-assigns it onto the " + "cached count, so emitting the delta would clobber on-screen inventory."); } [Test] public async Task GiftReceive_second_call_with_same_ids_does_not_double_grant() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); long viewerId = await factory.SeedViewerAsync(tutorialState: 31); await factory.SeedTutorialPresentsAsync(viewerId); using var client = factory.CreateAuthenticatedClient(viewerId); var preFirst = await factory.GetViewerCurrencyAsync(viewerId); var json = $$"""{"present_id_array":["71478626","71478627"],"state":1,{{BaseAuthBlock}}}"""; await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); var midPost = await factory.GetViewerCurrencyAsync(viewerId); Assert.That(midPost.Crystals - preFirst.Crystals, Is.EqualTo(400UL)); Assert.That(midPost.Rupees - preFirst.Rupees, Is.EqualTo(100UL)); var second = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json")); Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK)); var finalPost = await factory.GetViewerCurrencyAsync(viewerId); Assert.That(finalPost.Crystals, Is.EqualTo(midPost.Crystals), "Second claim of same present_ids must not re-grant."); Assert.That(finalPost.Rupees, Is.EqualTo(midPost.Rupees)); } }